diff --git a/src/chains/ethereum.ts b/src/chains/ethereum.ts index 4416353..cf7b531 100644 --- a/src/chains/ethereum.ts +++ b/src/chains/ethereum.ts @@ -15,6 +15,7 @@ import { TypedDataDefinition, parseTransaction, TransactionSerializable, + keccak256, } from "viem"; import { BaseTx, @@ -22,13 +23,15 @@ import { NearContractFunctionPayload, TxPayload, TransactionWithSignature, + MPCSignature, + RecoveryData, } from "../types/types"; import { MultichainContract } from "../mpcContract"; import { buildTxPayload, addSignature, populateTx } from "../utils/transaction"; import { Network } from "../network"; import { pickValidSignature } from "../utils/signature"; import { Web3WalletTypes } from "@walletconnect/web3wallet"; -import { wcRouter } from "../wallet-connect/handlers"; +import { offChainRecovery, wcRouter } from "../wallet-connect/handlers"; export class NearEthAdapter { readonly mpcContract: MultichainContract; @@ -169,16 +172,24 @@ export class NearEthAdapter { * @param serializedTransaction - Signed Ethereum transaction. * @returns Transaction Hash of relayed transaction. */ - private async relaySignedTransaction( - serializedTransaction: Hex + async relaySignedTransaction( + serializedTransaction: Hex, + wait: boolean = true ): Promise { const tx = parseTransaction(serializedTransaction); const network = Network.fromChainId(tx.chainId!); - const hash = await network.client.sendRawTransaction({ - serializedTransaction, - }); - console.log(`Transaction Confirmed: ${network.scanUrl}/tx/${hash}`); - return hash; + if (wait) { + const hash = await network.client.sendRawTransaction({ + serializedTransaction, + }); + console.log(`Transaction Confirmed: ${network.scanUrl}/tx/${hash}`); + return hash; + } else { + network.client.sendRawTransaction({ + serializedTransaction, + }); + return keccak256(serializedTransaction); + } } // Below code is inspired by https://github.com/Connor-ETHSeoul/near-viem @@ -251,17 +262,45 @@ export class NearEthAdapter { ]; } + async recoverSignature( + recoveryData: RecoveryData, + signatureData: MPCSignature + ): Promise { + const { big_r, big_s } = signatureData; + if (recoveryData.type === "eth_sendTransaction") { + const signature = addSignature( + { transaction: recoveryData.data as Hex, signature: signatureData }, + this.address + ); + // Returns relayed transaction hash (without waiting for confirmation). + return this.relaySignedTransaction(signature, false); + } + const r = `0x${big_r.substring(2)}` as Hex; + const s = `0x${big_s}` as Hex; + const sigs: [Hex, Hex] = [ + serializeSignature({ r, s, yParity: 0 }), + serializeSignature({ r, s, yParity: 1 }), + ]; + return offChainRecovery(recoveryData, sigs); + } + /// Mintbase Wallet async handleSessionRequest(request: Web3WalletTypes.SessionRequest): Promise<{ evmMessage: string | TransactionSerializable; nearPayload: NearContractFunctionPayload; + recoveryData: RecoveryData; }> { const { chainId, request: { method, params }, } = request.params; console.log(`Session Request of type ${method} for chainId ${chainId}`); - const { evmMessage, payload } = await wcRouter(method, chainId, params); + const { evmMessage, payload, signatureRecoveryData } = await wcRouter( + method, + chainId, + params + ); + console.log("Parsed Request:", payload, signatureRecoveryData); return { nearPayload: this.mpcContract.encodeSignatureRequestTx({ path: this.derivationPath, @@ -269,6 +308,7 @@ export class NearEthAdapter { key_version: 0, }), evmMessage, + recoveryData: signatureRecoveryData, }; } } diff --git a/src/types/types.ts b/src/types/types.ts index 6239aeb..8416df7 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,6 +1,6 @@ import { MultichainContract } from "../mpcContract"; import { FunctionCallAction } from "@near-wallet-selector/core"; -import { Hex } from "viem"; +import { Hex, SignableMessage } from "viem"; export interface BaseTx { /// Recipient of the transaction @@ -67,6 +67,27 @@ export interface MPCSignature { big_s: string; } +export interface MessageData { + address: Hex; + message: SignableMessage; +} + +export interface TypedMessageData { + address: Hex; + /* eslint-disable @typescript-eslint/no-explicit-any */ + types: any; + primaryType: any; + message: any; + domain: any; + /* eslint-enable @typescript-eslint/no-explicit-any */ +} + +export interface RecoveryData { + // TODO use enum! + type: string; + data: MessageData | TypedMessageData | Hex; +} + /** * Sufficient data required to construct a signed Ethereum Transaction. */ diff --git a/src/wallet-connect/handlers.ts b/src/wallet-connect/handlers.ts index 0d167ee..363beb5 100644 --- a/src/wallet-connect/handlers.ts +++ b/src/wallet-connect/handlers.ts @@ -6,8 +6,12 @@ import { hashTypedData, keccak256, serializeTransaction, + verifyMessage, + verifyTypedData, } from "viem"; import { populateTx, toPayload } from "../utils/transaction"; +import { MessageData, RecoveryData, TypedMessageData } from "../types/types"; +import { pickValidSignature } from "../utils/signature"; // Interface for Ethereum transaction parameters export interface EthTransactionParams { @@ -18,15 +22,19 @@ export interface EthTransactionParams { data?: Hex; } -// Interface for personal sign parameters (message and address) +/// Interface for personal sign parameters (message and address) export type PersonalSignParams = [Hex, Hex]; +/// Interface for eth_sign parameters (address and message) +export type EthSignParams = [Hex, Hex]; + // Interface for complex structured parameters like EIP-712 export type TypedDataParams = [Hex, string]; type SessionRequestParams = | EthTransactionParams[] | PersonalSignParams + | EthSignParams | TypedDataParams; export async function wcRouter( @@ -36,17 +44,35 @@ export async function wcRouter( ): Promise<{ evmMessage: TransactionSerializable | string; payload: number[]; + signatureRecoveryData: RecoveryData; }> { switch (method) { - // I believe {personal,eth}_sign both get routed to the same place. - case "eth_sign": + case "eth_sign": { + const [sender, messageHash] = params as EthSignParams; + return { + evmMessage: fromHex(messageHash, "string"), + payload: toPayload(hashMessage({ raw: messageHash })), + signatureRecoveryData: { + type: method, + data: { + address: sender, + message: { raw: messageHash }, + }, + }, + }; + } case "personal_sign": { const [messageHash, sender] = params as PersonalSignParams; - const message = fromHex(messageHash, "string"); - console.log(`Message to be signed by ${sender}: ${message}`); return { evmMessage: fromHex(messageHash, "string"), - payload: toPayload(hashMessage(messageHash)), + payload: toPayload(hashMessage({ raw: messageHash })), + signatureRecoveryData: { + type: method, + data: { + address: sender, + message: { raw: messageHash }, + }, + }, }; } case "eth_sendTransaction": { @@ -55,7 +81,7 @@ export async function wcRouter( { to: tx.to, chainId: parseInt(stripEip155Prefix(chainId)), - value: fromHex(tx.value || "0x", "bigint"), + value: fromHex(tx.value || "0x0", "bigint"), data: tx.data, gas: tx.gas ? fromHex(tx.gas, "bigint") : undefined, }, @@ -64,24 +90,68 @@ export async function wcRouter( return { payload: toPayload(keccak256(serializeTransaction(transaction))), evmMessage: transaction, + signatureRecoveryData: { + type: "eth_sendTransaction", + data: serializeTransaction(transaction), + }, }; } case "eth_signTypedData": case "eth_signTypedData_v4": { const [sender, dataString] = params as TypedDataParams; const typedData = JSON.parse(dataString); - console.log( - `Received Typed Data signature request from ${sender}: ${JSON.stringify(typedData)}` - ); return { evmMessage: dataString, payload: toPayload(hashTypedData(typedData)), + signatureRecoveryData: { + type: "eth_signTypedData", + data: { + address: sender, + types: typedData.types, + primaryType: typedData.primaryType, + message: typedData.message, + domain: typedData.domain, + }, + }, }; } } throw new Error(`Unhandled session_request method: ${method}`); } +export async function offChainRecovery( + recoveryData: RecoveryData, + sigs: [Hex, Hex] +): Promise { + let validity: [boolean, boolean]; + if (recoveryData.type === "personal_sign") { + validity = await Promise.all([ + verifyMessage({ + signature: sigs[0], + ...(recoveryData.data as MessageData), + }), + verifyMessage({ + signature: sigs[1], + ...(recoveryData.data as MessageData), + }), + ]); + } else if (recoveryData.type === "eth_signTypedData") { + validity = await Promise.all([ + verifyTypedData({ + signature: sigs[0], + ...(recoveryData.data as TypedMessageData), + }), + verifyTypedData({ + signature: sigs[1], + ...(recoveryData.data as TypedMessageData), + }), + ]); + } else { + throw new Error("Invalid Path"); + } + return pickValidSignature(validity, sigs); +} + function stripEip155Prefix(eip155Address: string): string { return eip155Address.split(":").pop() ?? ""; } diff --git a/tests/wc.handlers.test.ts b/tests/wc.handlers.test.ts index e49b5d7..e982151 100644 --- a/tests/wc.handlers.test.ts +++ b/tests/wc.handlers.test.ts @@ -1,71 +1,209 @@ -import { TransactionSerializable, toHex } from "viem"; +import { Hex, TransactionSerializable, serializeSignature, toHex } from "viem"; import { EthTransactionParams, PersonalSignParams, + offChainRecovery, wcRouter, } from "../src/wallet-connect/handlers"; +import { MessageData } from "../src/types/types"; describe("Wallet Connect", () => { const chainId = "eip155:11155111"; + const from = "0xa61d98854f7ab25402e3d12548a2e93a080c1f97"; + const to = "0xfff9976782d46cc05630d1f6ebab18b2324d6b14"; - it("wcRouter: personal_sign", async () => { - const messageString = "Hello!"; - const request = { - method: "personal_sign", - params: [ - toHex(messageString), - "0xa61d98854f7ab25402e3d12548a2e93a080c1f97", - ], - }; - - const { evmMessage, payload } = await wcRouter( - request.method, - chainId, - request.params as PersonalSignParams - ); - expect(evmMessage).toEqual(messageString); - expect(payload).toEqual([ - 129, 83, 250, 146, 102, 140, 185, 9, 243, 111, 112, 21, 11, 157, 12, 23, - 202, 85, 99, 164, 77, 162, 209, 137, 199, 133, 194, 59, 178, 150, 153, 78, - ]); + describe("wcRouter: eth_sign & personal_sign", () => { + it("hello message", async () => { + const messageString = "Hello!"; + const request = { + method: "eth_sign", + params: [from, toHex(messageString)], + }; + + const { evmMessage, payload } = await wcRouter( + request.method, + chainId, + request.params as PersonalSignParams + ); + expect(evmMessage).toEqual(messageString); + console.log(payload); + expect(payload).toEqual([ + 140, 57, 188, 66, 128, 51, 12, 191, 241, 190, 96, 50, 8, 251, 249, 208, + 157, 27, 241, 60, 23, 124, 29, 153, 245, 135, 109, 181, 125, 67, 182, + 82, + ]); + }); + + it("opensea login", async () => { + const request = { + method: "personal_sign", + params: [ + "0x57656c636f6d6520746f204f70656e536561210a0a436c69636b20746f207369676e20696e20616e642061636365707420746865204f70656e536561205465726d73206f662053657276696365202868747470733a2f2f6f70656e7365612e696f2f746f732920616e64205072697661637920506f6c696379202868747470733a2f2f6f70656e7365612e696f2f70726976616379292e0a0a5468697320726571756573742077696c6c206e6f742074726967676572206120626c6f636b636861696e207472616e73616374696f6e206f7220636f737420616e792067617320666565732e0a0a57616c6c657420616464726573733a0a3078663131633232643631656364376231616463623662343335343266653861393662393332386463370a0a4e6f6e63653a0a32393731633731312d623739382d343433342d613633312d316333663133656665353365", + "0xf11c22d61ecd7b1adcb6b43542fe8a96b9328dc7", + ], + }; + + const { evmMessage, payload } = await wcRouter( + request.method, + chainId, + request.params as PersonalSignParams + ); + expect(evmMessage).toEqual( + `Welcome to OpenSea! + +Click to sign in and accept the OpenSea Terms of Service (https://opensea.io/tos) and Privacy Policy (https://opensea.io/privacy). + +This request will not trigger a blockchain transaction or cost any gas fees. + +Wallet address: +0xf11c22d61ecd7b1adcb6b43542fe8a96b9328dc7 + +Nonce: +2971c711-b798-4434-a631-1c3f13efe53e` + ); + expect(payload).toEqual([ + 219, 231, 195, 249, 2, 161, 186, 203, 13, 63, 169, 203, 233, 111, 203, + 91, 4, 166, 92, 92, 217, 141, 180, 168, 176, 123, 102, 85, 38, 115, 1, + 71, + ]); + }); + + it("manifold login", async () => { + const request = { + method: "personal_sign", + params: [ + "0x506c65617365207369676e2074686973206d65737361676520746f20616363657373204d616e69666f6c642053747564696f0a0a4368616c6c656e67653a2034313133666333616232636336306635643539356232653535333439663165656335366664306337306434323837303831666537313536383438323633363236", + "0xf11c22d61ecd7b1adcb6b43542fe8a96b9328dc7", + ], + }; + + const { evmMessage, payload } = await wcRouter( + request.method, + chainId, + request.params as PersonalSignParams + ); + expect(evmMessage).toEqual( + `Please sign this message to access Manifold Studio + +Challenge: 4113fc3ab2cc60f5d595b2e55349f1eec56fd0c70d4287081fe7156848263626` + ); + expect(payload).toEqual([ + 211, 164, 197, 156, 45, 221, 33, 214, 110, 59, 107, 27, 229, 254, 102, + 73, 86, 215, 129, 196, 48, 209, 241, 41, 108, 165, 177, 200, 81, 31, 1, + 104, + ]); + }); }); - it("wcRouter: sendTransaction", async () => { - const request = { - method: "eth_sendTransaction", - params: [ - { - gas: "0xd31d", - value: "0x16345785d8a0000", - from: "0xa61d98854f7ab25402e3d12548a2e93a080c1f97", - to: "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - data: "0xd0e30db0", - }, - ], - }; - - const { evmMessage } = await wcRouter( - request.method, - chainId, - request.params as EthTransactionParams[] - ); - const tx = evmMessage as TransactionSerializable; - - delete tx.maxFeePerGas; - delete tx.maxPriorityFeePerGas; - delete tx.nonce; - - expect(tx).toEqual({ - account: "0xa61d98854f7ab25402e3d12548a2e93a080c1f97", - chainId: 11155111, - data: "0xd0e30db0", - gas: 54045n, - to: "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - value: 100000000000000000n, + describe("wcRouter: eth_sendTransaction", () => { + it("with value", async () => { + const request = { + method: "eth_sendTransaction", + params: [ + { + gas: "0xd31d", + value: "0x16345785d8a0000", + from, + to, + data: "0xd0e30db0", + }, + ], + }; + + const { evmMessage } = await wcRouter( + request.method, + chainId, + request.params as EthTransactionParams[] + ); + const tx = evmMessage as TransactionSerializable; + + delete tx.maxFeePerGas; + delete tx.maxPriorityFeePerGas; + delete tx.nonce; + + expect(tx).toEqual({ + account: from, + chainId: 11155111, + data: "0xd0e30db0", + gas: 54045n, + to, + value: 100000000000000000n, + }); + /// can't test payload: its non-deterministic because of gas values! + }); + + it("null value", async () => { + const request = { + method: "eth_sendTransaction", + params: [ + { + gas: "0xa8c3", + from, + to, + data: "0x2e1a7d4d000000000000000000000000000000000000000000000000002386f26fc10000", + }, + ], + }; + + const { evmMessage } = await wcRouter( + request.method, + chainId, + request.params as EthTransactionParams[] + ); + const tx = evmMessage as TransactionSerializable; + + delete tx.maxFeePerGas; + delete tx.maxPriorityFeePerGas; + delete tx.nonce; + + expect(tx).toEqual({ + account: from, + chainId: 11155111, + data: "0x2e1a7d4d000000000000000000000000000000000000000000000000002386f26fc10000", + gas: 43203n, + to, + value: 0n, + }); + /// can't test payload: its non-deterministic because of gas values! + }); + + it("null data", async () => { + const request = { + method: "eth_sendTransaction", + params: [ + { + gas: "0xa8c3", + from, + to, + value: "0x01", + }, + ], + }; + + const { evmMessage } = await wcRouter( + request.method, + chainId, + request.params as EthTransactionParams[] + ); + const tx = evmMessage as TransactionSerializable; + + delete tx.maxFeePerGas; + delete tx.maxPriorityFeePerGas; + delete tx.nonce; + + expect(tx).toEqual({ + account: from, + chainId: 11155111, + data: "0x", + gas: 43203n, + to, + value: 1n, + }); + /// can't test payload: its non-deterministic because of gas values! }); - /// can't test payload: its non-deterministic because of gas values! }); - it("wcRouter: eth_signTypedData", async () => { - const jsonStr = `{ + describe("wcRouter: eth_signTypedData", () => { + it("Cowswap Order", async () => { + const jsonStr = `{ "types": { "Permit": [ {"name": "owner", "type": "address"}, @@ -96,20 +234,47 @@ describe("Wallet Connect", () => { "deadline": "1872873982" } }`; - const request = { - method: "eth_signTypedData_v4", - params: ["0xa61d98854f7ab25402e3d12548a2e93a080c1f97", jsonStr], - }; - - const { evmMessage, payload } = await wcRouter( - request.method, - chainId, - request.params as PersonalSignParams - ); - expect(evmMessage).toEqual(request.params[1]); - expect(payload).toEqual([ - 154, 201, 197, 176, 122, 212, 161, 42, 56, 12, 218, 93, 39, 197, 249, 144, - 53, 126, 250, 19, 85, 168, 82, 131, 104, 184, 46, 112, 237, 228, 48, 12, - ]); + const request = { + method: "eth_signTypedData_v4", + params: [from, jsonStr], + }; + + const { evmMessage, payload } = await wcRouter( + request.method, + chainId, + request.params as PersonalSignParams + ); + expect(evmMessage).toEqual(request.params[1]); + expect(payload).toEqual([ + 154, 201, 197, 176, 122, 212, 161, 42, 56, 12, 218, 93, 39, 197, 249, + 144, 53, 126, 250, 19, 85, 168, 82, 131, 104, 184, 46, 112, 237, 228, + 48, 12, + ]); + }); + }); + + describe("offChainRecovery: personal_sign", () => { + it.only("recovering signature", async () => { + const recoveryData = { + type: "personal_sign", + data: { + address: "0xf11c22d61ecd7b1adcb6b43542fe8a96b9328dc7", + message: { + raw: "0x57656c636f6d6520746f204f70656e536561210a0a436c69636b20746f207369676e20696e20616e642061636365707420746865204f70656e536561205465726d73206f662053657276696365202868747470733a2f2f6f70656e7365612e696f2f746f732920616e64205072697661637920506f6c696379202868747470733a2f2f6f70656e7365612e696f2f70726976616379292e0a0a5468697320726571756573742077696c6c206e6f742074726967676572206120626c6f636b636861696e207472616e73616374696f6e206f7220636f737420616e792067617320666565732e0a0a57616c6c657420616464726573733a0a3078663131633232643631656364376231616463623662343335343266653861393662393332386463370a0a4e6f6e63653a0a63336432623238622d623964652d346239662d383935362d316336663739373133613431", + }, + } as MessageData, + }; + const r = + "0x491E245DB3914B85807F3807F2125B9ED9722D0E9F3FA0FE325B31893FA5E693"; + const s = + "0x387178AE4A51F304556C1B2E9DD24F1120D073F93017AF006AD801A639214EA6"; + const sigs: [Hex, Hex] = [ + serializeSignature({ r, s, yParity: 0 }), + serializeSignature({ r, s, yParity: 1 }), + ]; + + const signature = await offChainRecovery(recoveryData, sigs); + console.log(signature); + }); }); });