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
19 changes: 13 additions & 6 deletions tests/ecdsa-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Program } from "@coral-xyz/anchor";
import { EcdsaProxy } from "../target/types/ecdsa_proxy";
import { expect } from "chai";
import { Keypair, PublicKey, TransactionInstruction } from "@solana/web3.js";
import { hexToBytes } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import {
createMint,
Expand All @@ -12,8 +13,8 @@ import {
createTransferInstruction,
} from "@solana/spl-token";
import {
deriveWalletPDA,
ethAddressFromAccount,
WALLET_SEED,
WALLET_PREFIX,
signMessage,
makeHighS,
toAnchorInnerInstructions,
Expand All @@ -32,8 +33,8 @@ describe("ecdsa-proxy", () => {
const evmWallet = privateKeyToAccount(generatePrivateKey());
const evmWallet2 = privateKeyToAccount(generatePrivateKey());

const ethAddress = ethAddressFromAccount(evmWallet);
const ethAddress2 = ethAddressFromAccount(evmWallet2);
const ethAddress = hexToBytes(evmWallet.address);
const ethAddress2 = hexToBytes(evmWallet2.address);

let walletPDA: PublicKey;
let walletBump: number;
Expand Down Expand Up @@ -78,8 +79,14 @@ describe("ecdsa-proxy", () => {
}

before(async () => {
[walletPDA, walletBump] = deriveWalletPDA(ethAddress, programId);
[wallet2PDA] = deriveWalletPDA(ethAddress2, programId);
[walletPDA, walletBump] = PublicKey.findProgramAddressSync(
[WALLET_SEED, WALLET_PREFIX, ethAddress],
programId
);
[wallet2PDA] = PublicKey.findProgramAddressSync(
[WALLET_SEED, WALLET_PREFIX, ethAddress2],
programId
);

mint = await createMint(provider.connection, payer, payer.publicKey, null, 6);
});
Expand Down
13 changes: 3 additions & 10 deletions tests/helpers/evm-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,14 @@ export interface IndexedInnerInstruction {
/** Must match the hardcoded CHAIN_ID in constants.rs (devnet = 2) */
export const CHAIN_ID = 2n;

const WALLET_SEED = Buffer.from("ecdsa_proxy");
const WALLET_PREFIX = Buffer.from("wallet");
/** PDA seeds — must match constants.rs WALLET_SEED / WALLET_PREFIX */
export const WALLET_SEED = Buffer.from("ecdsa_proxy");
export const WALLET_PREFIX = Buffer.from("wallet");

const SECP256K1_ORDER = BigInt(
"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"
);

export function deriveWalletPDA(ethAddress: Buffer, programId: PublicKey): [PublicKey, number] {
return PublicKey.findProgramAddressSync([WALLET_SEED, WALLET_PREFIX, ethAddress], programId);
}

export function ethAddressFromAccount(account: PrivateKeyAccount): Buffer {
return Buffer.from(hexToBytes(account.address));
}

/**
* Convert pubkey-based InnerInstructions to index-based, given a remaining_accounts list.
* Returns the indexed instructions that match the indices in remainingAccounts.
Expand Down
67 changes: 37 additions & 30 deletions tests/helpers/mpc-signer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { contracts, constants, chainAdapters } from "signet.js";
import { PublicKey } from "@solana/web3.js";
import { createPublicClient, createWalletClient, http, hexToBytes, type Hex } from "viem";
import { createPublicClient, createWalletClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";
import { computeInnerHash, type IndexedInnerInstruction } from "./evm-signer";
Expand All @@ -10,15 +10,10 @@ const { abi: chainSigAbi } = contracts.evm.utils.ChainSignaturesContractABI;
const { EVM } = chainAdapters.evm;

type MpcContract = InstanceType<typeof ChainSignatureContract>;
type EvmAdapter = InstanceType<typeof EVM>;
type SepoliaPublicClient = ReturnType<typeof createPublicClient>;

export interface MpcSigner {
contract: MpcContract;
evm: InstanceType<typeof EVM>;
predecessor: string;
publicClient: ReturnType<typeof createPublicClient>;
}

export function createMpcSigner(sepoliaPrivateKey: string, sepoliaRpcUrl: string): MpcSigner {
export function createMpcSigner(sepoliaPrivateKey: string, sepoliaRpcUrl: string) {
const account = privateKeyToAccount(sepoliaPrivateKey as `0x${string}`);
const publicClient = createPublicClient({
chain: sepolia,
Expand All @@ -34,26 +29,15 @@ export function createMpcSigner(sepoliaPrivateKey: string, sepoliaRpcUrl: string
walletClient,
contractAddress: constants.CONTRACT_ADDRESSES.ETHEREUM.TESTNET as `0x${string}`,
});
const evm = new EVM({ publicClient, contract });
const evmAdapter = new EVM({ publicClient, contract });

return { contract, evm, predecessor: account.address, publicClient };
}

export async function deriveMpcEthAddress(
signer: MpcSigner,
path: string,
keyVersion: number
): Promise<Buffer> {
const { address } = await signer.evm.deriveAddressAndPublicKey(
signer.predecessor,
path,
keyVersion
);
return Buffer.from(hexToBytes(address as `0x${string}`));
return { contract, evmAdapter, predecessor: account.address, publicClient };
}

export async function signMessageMpc(
signer: MpcSigner,
contract: MpcContract,
evmAdapter: EvmAdapter,
publicClient: SepoliaPublicClient,
programId: PublicKey,
nonce: bigint,
remainingAccountKeys: PublicKey[],
Expand All @@ -66,13 +50,13 @@ export async function signMessageMpc(
sepoliaRespondTxHash: string;
}> {
const innerHash = computeInnerHash(programId, nonce, remainingAccountKeys, indexedInstructions);
const { hashToSign } = await signer.evm.prepareMessageForSigning({ raw: innerHash });
const { hashToSign } = await evmAdapter.prepareMessageForSigning({ raw: innerHash });

const signArgs = { payload: hashToSign, path, key_version: 1 };
const { txHash, requestId } = await signer.contract.createSignatureRequest(signArgs);
const receipt = await signer.publicClient.waitForTransactionReceipt({ hash: txHash });
const { txHash, requestId } = await contract.createSignatureRequest(signArgs);
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });

const pollResult = await signer.contract.pollForRequestId({
const pollResult = await contract.pollForRequestId({
requestId,
payload: signArgs.payload,
path: signArgs.path,
Expand All @@ -87,7 +71,7 @@ export async function signMessageMpc(
);
}

const respondLogs = await signer.publicClient.getContractEvents({
const respondLogs = await publicClient.getContractEvents({
address: constants.CONTRACT_ADDRESSES.ETHEREUM.TESTNET as Hex,
abi: chainSigAbi,
eventName: "SignatureResponded",
Expand All @@ -107,3 +91,26 @@ export async function signMessageMpc(
sepoliaRespondTxHash: respondTxHash,
};
}

export async function signMessageMpcSimple(
contract: MpcContract,
evmAdapter: EvmAdapter,
programId: PublicKey,
nonce: bigint,
remainingAccountKeys: PublicKey[],
indexedInstructions: IndexedInnerInstruction[],
path: string
): Promise<{ signature: Buffer; recoveryId: number }> {
const innerHash = computeInnerHash(programId, nonce, remainingAccountKeys, indexedInstructions);
const { hashToSign } = await evmAdapter.prepareMessageForSigning({ raw: innerHash });

const rsv = await contract.sign(
{ payload: hashToSign, path, key_version: 1 },
{ sign: {}, retry: { delay: 5_000, retryCount: 12 } }
);

return {
signature: Buffer.concat([Buffer.from(rsv.r, "hex"), Buffer.from(rsv.s, "hex")]),
recoveryId: rsv.v - 27,
};
}
118 changes: 96 additions & 22 deletions tests/mpc-ecdsa-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,41 @@ import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import type { EcdsaProxy } from "../target/types/ecdsa_proxy";
import { expect } from "chai";
import { Connection, Keypair } from "@solana/web3.js";
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
import { hexToBytes, type Hex } from "viem";
import {
type Account,
createAssociatedTokenAccountInstruction,
createMint,
createTransferInstruction,
getAccount,
getAssociatedTokenAddressSync,
getOrCreateAssociatedTokenAccount,
mintTo,
getAccount,
createTransferInstruction,
} from "@solana/spl-token";
import {
deriveWalletPDA,
WALLET_SEED,
WALLET_PREFIX,
toAnchorInnerInstructions,
toIndexedInnerInstructions,
buildRemainingAccounts,
} from "./helpers/evm-signer";
import { createMpcSigner, deriveMpcEthAddress, signMessageMpc } from "./helpers/mpc-signer";
import { createMpcSigner, signMessageMpc, signMessageMpcSimple } from "./helpers/mpc-signer";

describe("mpc-ecdsa-proxy", () => {
let provider: anchor.AnchorProvider;
let program: Program<EcdsaProxy>;
let payer: Keypair;
let walletPDA: PublicKey;
let mint: PublicKey;
let pdaAta: Account;
let ethAddress: Uint8Array;

const MPC_PATH = `ecdsa-sol-proxy,${Date.now()}`;
const { contract, evmAdapter, predecessor, publicClient } = createMpcSigner(
process.env.SEPOLIA_PRIVATE_KEY!,
process.env.SEPOLIA_RPC_URL!
);

before(async () => {
const connection = new Connection(process.env.SOLANA_DEVNET_RPC_URL!, "confirmed");
Expand All @@ -37,34 +50,31 @@ describe("mpc-ecdsa-proxy", () => {
anchor.setProvider(provider);

program = anchor.workspace.ecdsaProxy as Program<EcdsaProxy>;
});

it("e2e: MPC-signed SPL token transfer via ecdsa-proxy", async () => {
const programId = program.programId;
const signer = createMpcSigner(process.env.SEPOLIA_PRIVATE_KEY!, process.env.SEPOLIA_RPC_URL!);

// Derive ETH address from MPC and initialize wallet PDA
const ethAddress = await deriveMpcEthAddress(signer, MPC_PATH, 1);
const [walletPDA] = deriveWalletPDA(ethAddress, programId);
const { address } = await evmAdapter.deriveAddressAndPublicKey(predecessor, MPC_PATH, 1);
ethAddress = hexToBytes(address as Hex);
[walletPDA] = PublicKey.findProgramAddressSync(
[WALLET_SEED, WALLET_PREFIX, ethAddress],
program.programId
);

const initTxHash = await program.methods
.initializeWallet(Array.from(ethAddress))
.accounts({ payer: payer.publicKey })
.rpc();
console.log("Solana initializeWallet tx:", initTxHash);

// Mint tokens to PDA
const mint = await createMint(provider.connection, payer, payer.publicKey, null, 6);
const pdaAta = await getOrCreateAssociatedTokenAccount(
mint = await createMint(provider.connection, payer, payer.publicKey, null, 6);
pdaAta = await getOrCreateAssociatedTokenAccount(
provider.connection,
payer,
mint,
walletPDA,
true
);
await mintTo(provider.connection, payer, mint, pdaAta.address, payer, 1_000_000);
await mintTo(provider.connection, payer, mint, pdaAta.address, payer, 2_000_000);
});

// Build transfer instruction
it("e2e: MPC-signed SPL token transfer via ecdsa-proxy", async () => {
const recipientAta = await getOrCreateAssociatedTokenAccount(
provider.connection,
payer,
Expand All @@ -80,14 +90,22 @@ describe("mpc-ecdsa-proxy", () => {
transferAmount
);

// Sign via MPC and execute
const remaining = buildRemainingAccounts([innerIx]);
const remainingKeys = remaining.map((r) => r.pubkey);
const indexed = toIndexedInnerInstructions([innerIx], remainingKeys);
const nonce = 0n;

const { signature, recoveryId, sepoliaRequestTxHash, sepoliaRespondTxHash } =
await signMessageMpc(signer, programId, nonce, remainingKeys, indexed, MPC_PATH);
await signMessageMpc(
contract,
evmAdapter,
publicClient,
program.programId,
nonce,
remainingKeys,
indexed,
MPC_PATH
);
console.log("Sepolia MPC request tx:", sepoliaRequestTxHash);
console.log("Sepolia MPC respond tx:", sepoliaRespondTxHash);

Expand All @@ -103,8 +121,64 @@ describe("mpc-ecdsa-proxy", () => {
.rpc();
console.log("Solana execute tx:", executeTxHash);

// Verify tokens moved
const recipientAccount = await getAccount(provider.connection, recipientAta.address);
expect(Number(recipientAccount.amount)).to.equal(Number(transferAmount));
});

it("e2e: ATA creation + token transfer via contract.sign (CPI depth 2)", async () => {
// Create a separate mint so this test is self-contained
const mintC = await createMint(provider.connection, payer, payer.publicKey, null, 6);
const pdaAtaC = await getOrCreateAssociatedTokenAccount(
provider.connection,
payer,
mintC,
walletPDA,
true
);
await mintTo(provider.connection, payer, mintC, pdaAtaC.address, payer, 500_000);

// Compute the recipient's ATA address (account does NOT exist yet)
const recipient = Keypair.generate().publicKey;
const recipientAta = getAssociatedTokenAddressSync(mintC, recipient, true);

const transferAmount = 100_000n;
const innerIxs = [
// 1. Create ATA via ATA program — CPI depth 2 (ATA program → System + Token programs)
// Payer (test wallet) funds it; signer status propagates from outer tx
createAssociatedTokenAccountInstruction(payer.publicKey, recipientAta, recipient, mintC),
// 2. Transfer tokens to the just-created ATA — PDA signs via invoke_signed
createTransferInstruction(pdaAtaC.address, recipientAta, walletPDA, transferAmount),
];

const remaining = buildRemainingAccounts(innerIxs);
const remainingKeys = remaining.map((r) => r.pubkey);
const indexed = toIndexedInnerInstructions(innerIxs, remainingKeys);

const walletState = await program.account.walletState.fetch(walletPDA);
const nonce = BigInt(walletState.nonce.toString());

const { signature, recoveryId } = await signMessageMpcSimple(
contract,
evmAdapter,
program.programId,
nonce,
remainingKeys,
indexed,
MPC_PATH
);

await program.methods
.execute(
Array.from(signature),
recoveryId,
new anchor.BN(nonce.toString()),
toAnchorInnerInstructions(indexed)
)
.accounts({ walletState: walletPDA, payer: payer.publicKey })
.remainingAccounts(remaining)
.rpc();

const recipientAccount = await getAccount(provider.connection, recipientAta);
expect(Number(recipientAccount.amount)).to.equal(Number(transferAmount));
});
});
Loading