Skip to content
Merged
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
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
ci:
name: Check & Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- uses: Swatinem/rust-cache@v2

- name: Install Solana CLI
run: |
sh -c "$(curl -sSfL https://release.anza.xyz/v2.3.0/install)"
echo "$HOME/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH

- run: cargo install --git https://github.com/coral-xyz/anchor avm --force
- run: avm install 0.32.1 && avm use 0.32.1

- run: npm ci
- run: anchor build
- run: npm run check

- name: Generate keypair
run: solana-keygen new --no-bip39-passphrase --force

- run: anchor test --skip-build
77 changes: 47 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,51 @@ Built with [Anchor](https://www.anchor-lang.com/) 0.32.

## How it works

### Direct signing (local ETH key)

1. **Build** inner instructions off-chain (SPL transfers, swaps, etc.)
2. **Compute** the message hash (see [below](#message-hash))
3. **Sign** the hash with an ETH private key → `(signature, recovery_id)`
4. **Submit** the Solana tx with the signature + indexed inner instructions
5. **On-chain** — the program verifies the ECDSA signature, recovers the ETH address, asserts the nonce, and executes each inner instruction as a CPI from the PDA

### MPC signing (via [Signet](https://docs.sig.network) network)

Instead of a local private key, an EVM wallet requests a distributed signature from the [Signet MPC network](https://docs.sig.network). The on-chain program does not distinguish between local-key and MPC signatures — verification is identical.

1. **Derive** an ETH address from the MPC network using a derivation path
2. **Initialize** the wallet PDA on Solana with that derived address
3. **Build** inner instructions + compute the message hash (same as direct signing)
4. **Request** a signature via `createSignatureRequest(hash, path)` on the Signet `ChainSignatureContract` (Sepolia) — the MPC network produces `(r, s, v)`
5. **Submit** the signature to the Solana program
6. **On-chain** — identical verification: ECDSA recovery, address comparison, nonce check, CPI dispatch

See the [Signet docs](https://docs.sig.network) for more on chain signatures and MPC key derivation. The MPC test (`tests/mpc-ecdsa-proxy.ts`) requires Sepolia and Solana devnet credentials via environment variables.

### Message hash

Both paths produce the same hash structure:

```
┌──────────────────────────────────────────────────────────────────────────┐
│ Off-chain (Client) │
│ │
│ 1. Build inner instructions (SPL transfers, swaps, etc.) │
│ 2. Compute message hash: │
│ keccak256( │
│ "\x19Ethereum Signed Message:\n32" || │
│ keccak256(chain_id || program_id || nonce || │
│ keccak256(remaining_accounts) || │
│ keccak256(borsh(inner_instructions))) │
│ ) │
│ 3. Sign with ETH private key → (signature, recovery_id) │
│ 4. Submit Solana tx with signature + indexed inner instructions │
└─────────────────────────────────┬────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ On-chain (Solana Program) │
│ │
│ 1. Assert nonce == wallet_state.nonce │
│ 2. Reject high-S signatures (malleability) │
│ 3. Recompute message hash from tx data │
│ 4. secp256k1_recover(hash, signature, recovery_id) → pubkey │
│ 5. keccak256(pubkey)[12..] → recovered ETH address │
│ 6. Assert recovered address == wallet_state.eth_address │
│ 7. Increment nonce │
│ 8. invoke_signed() each inner instruction with PDA as signer │
└──────────────────────────────────────────────────────────────────────────┘
keccak256(
"\x19Ethereum Signed Message:\n32" ||
keccak256(chain_id || program_id || nonce ||
keccak256(remaining_accounts) ||
keccak256(borsh(inner_instructions)))
)
```

### On-chain verification

1. Assert `nonce == wallet_state.nonce`
2. Reject high-S signatures (malleability)
3. Recompute message hash from tx data
4. `secp256k1_recover(hash, signature, recovery_id)` → pubkey
5. `keccak256(pubkey)[12..]` → recovered ETH address
6. Assert recovered address == `wallet_state.eth_address`
7. Increment nonce
8. `invoke_signed()` each inner instruction with PDA as signer

## Instructions

| Instruction | Discriminator | Description |
Expand Down Expand Up @@ -98,8 +112,11 @@ programs/ecdsa-proxy/src/
└── close_wallet.rs

tests/
├── ecdsa-proxy.ts # Integration tests
└── helpers/evm-signer.ts # TypeScript signing (mirrors on-chain hashing)
├── 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
```

## License
Expand Down
Loading
Loading