diff --git a/tests/ecdsa-proxy.ts b/tests/ecdsa-proxy.ts index a132262..3e60722 100644 --- a/tests/ecdsa-proxy.ts +++ b/tests/ecdsa-proxy.ts @@ -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, @@ -12,8 +13,8 @@ import { createTransferInstruction, } from "@solana/spl-token"; import { - deriveWalletPDA, - ethAddressFromAccount, + WALLET_SEED, + WALLET_PREFIX, signMessage, makeHighS, toAnchorInnerInstructions, @@ -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; @@ -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); }); diff --git a/tests/helpers/evm-signer.ts b/tests/helpers/evm-signer.ts index 5357315..a392c0d 100644 --- a/tests/helpers/evm-signer.ts +++ b/tests/helpers/evm-signer.ts @@ -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. diff --git a/tests/helpers/mpc-signer.ts b/tests/helpers/mpc-signer.ts index d9d2189..7491d4c 100644 --- a/tests/helpers/mpc-signer.ts +++ b/tests/helpers/mpc-signer.ts @@ -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"; @@ -10,15 +10,10 @@ const { abi: chainSigAbi } = contracts.evm.utils.ChainSignaturesContractABI; const { EVM } = chainAdapters.evm; type MpcContract = InstanceType; +type EvmAdapter = InstanceType; +type SepoliaPublicClient = ReturnType; -export interface MpcSigner { - contract: MpcContract; - evm: InstanceType; - predecessor: string; - publicClient: ReturnType; -} - -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, @@ -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 { - 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[], @@ -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, @@ -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", @@ -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, + }; +} diff --git a/tests/mpc-ecdsa-proxy.ts b/tests/mpc-ecdsa-proxy.ts index 1df1c66..4561273 100644 --- a/tests/mpc-ecdsa-proxy.ts +++ b/tests/mpc-ecdsa-proxy.ts @@ -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; 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"); @@ -37,15 +50,12 @@ describe("mpc-ecdsa-proxy", () => { anchor.setProvider(provider); program = anchor.workspace.ecdsaProxy as Program; - }); - - 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)) @@ -53,18 +63,18 @@ describe("mpc-ecdsa-proxy", () => { .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, @@ -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); @@ -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)); + }); });