From 2fda71b43bf87559b451621140e0b273b42ea8a3 Mon Sep 17 00:00:00 2001 From: Chase Fleming Date: Thu, 30 Jan 2025 13:50:44 -0800 Subject: [PATCH] Implement `personal_sign` (#2095) * Add sign message * Update packages/fcl-ethereum-provider/src/accounts/account-manager.ts Co-authored-by: Jordan Ribbink <17958158+jribbink@users.noreply.github.com> * Push fix * Remove comment * Update packages/fcl-ethereum-provider/src/rpc/handlers/personal-sign.ts Co-authored-by: Jordan Ribbink <17958158+jribbink@users.noreply.github.com> * Update packages/fcl-ethereum-provider/src/accounts/account-manager.ts Co-authored-by: Jordan Ribbink <17958158+jribbink@users.noreply.github.com> * Update packages/fcl-ethereum-provider/src/rpc/handlers/personal-sign.ts Co-authored-by: Jordan Ribbink <17958158+jribbink@users.noreply.github.com> * Check auth with coa address * Add tests * Fix params and prettier * Run prettier * Use RLP * Change path * hex array * Fix RLP test * Run prettier --------- Co-authored-by: Chase Fleming <1666730+chasefleming@users.noreply.github.com> Co-authored-by: Jordan Ribbink <17958158+jribbink@users.noreply.github.com> --- package-lock.json | 1 + packages/fcl-ethereum-provider/package.json | 1 + .../src/accounts/account-manager.test.ts | 85 +++++++++++++++++++ .../src/accounts/account-manager.ts | 45 +++++++++- .../src/rpc/handlers/personal-sign.ts | 11 +++ .../src/rpc/rpc-processor.ts | 7 ++ .../fcl-ethereum-provider/src/types/eth.ts | 3 + 7 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 packages/fcl-ethereum-provider/src/rpc/handlers/personal-sign.ts create mode 100644 packages/fcl-ethereum-provider/src/types/eth.ts diff --git a/package-lock.json b/package-lock.json index 7fda0fe47..c638df32a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29202,6 +29202,7 @@ "dependencies": { "@babel/runtime": "^7.25.7", "@onflow/fcl": "1.13.4", + "@onflow/rlp": "^1.2.3", "@walletconnect/jsonrpc-http-connection": "^1.0.8", "@walletconnect/jsonrpc-provider": "^1.0.14" }, diff --git a/packages/fcl-ethereum-provider/package.json b/packages/fcl-ethereum-provider/package.json index a25e0e6c3..7986990a3 100644 --- a/packages/fcl-ethereum-provider/package.json +++ b/packages/fcl-ethereum-provider/package.json @@ -38,6 +38,7 @@ "dependencies": { "@babel/runtime": "^7.25.7", "@onflow/fcl": "1.13.4", + "@onflow/rlp": "^1.2.3", "@walletconnect/jsonrpc-http-connection": "^1.0.8", "@walletconnect/jsonrpc-provider": "^1.0.14" } diff --git a/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts b/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts index 7186b0ddb..ab21c7eaa 100644 --- a/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts +++ b/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts @@ -1,6 +1,7 @@ import {AccountManager} from "./account-manager" import {mockUser} from "../__mocks__/fcl" import * as fcl from "@onflow/fcl" +import * as rlp from "@onflow/rlp" import {CurrentUser} from "@onflow/typedefs" jest.mock("@onflow/fcl", () => { @@ -14,6 +15,11 @@ jest.mock("@onflow/fcl", () => { } }) +jest.mock("@onflow/rlp", () => ({ + encode: jest.fn(), + Buffer: jest.requireActual("@onflow/rlp").Buffer, +})) + const mockFcl = jest.mocked(fcl) const mockQuery = jest.mocked(fcl.query) @@ -333,3 +339,82 @@ describe("send transaction", () => { ) }) }) + +describe("signMessage", () => { + let accountManager: AccountManager + let user: jest.Mocked + + beforeEach(() => { + user = mockUser() + accountManager = new AccountManager(user) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it("should throw an error if the COA address is not available", async () => { + accountManager["coaAddress"] = null + + await expect( + accountManager.signMessage("Test message", "0x1234") + ).rejects.toThrow( + "COA address is not available. User might not be authenticated." + ) + }) + + it("should throw an error if the signer address does not match the COA address", async () => { + accountManager["coaAddress"] = "0xCOA1" + + await expect( + accountManager.signMessage("Test message", "0xDIFFERENT") + ).rejects.toThrow("Signer address does not match authenticated COA address") + }) + + it("should successfully sign a message and return an RLP-encoded proof", async () => { + accountManager["coaAddress"] = "0xCOA1" + const mockSignature = "0xabcdef1234567890" + const mockRlpEncoded = "f86a808683abcdef682f73746f726167652f65766d" + + user.signUserMessage = jest + .fn() + .mockResolvedValue([{addr: "0xCOA1", keyId: 0, signature: mockSignature}]) + + jest.mocked(rlp.encode).mockReturnValue(Buffer.from(mockRlpEncoded, "hex")) + + const proof = await accountManager.signMessage("Test message", "0xCOA1") + + expect(proof).toBe(`0x${mockRlpEncoded}`) + + expect(user.signUserMessage).toHaveBeenCalledWith("Test message") + + expect(rlp.encode).toHaveBeenCalledWith([ + [0], + expect.any(Buffer), + "/public/evm", + [mockSignature], + ]) + }) + + it("should throw an error if signUserMessage returns an empty array", async () => { + accountManager["coaAddress"] = "0xCOA1" + + user.signUserMessage = jest.fn().mockResolvedValue([]) + + await expect( + accountManager.signMessage("Test message", "0xCOA1") + ).rejects.toThrow("Failed to sign message") + }) + + it("should throw an error if signUserMessage fails", async () => { + accountManager["coaAddress"] = "0xCOA1" + + user.signUserMessage = jest + .fn() + .mockRejectedValue(new Error("Signing failed")) + + await expect( + accountManager.signMessage("Test message", "0xCOA1") + ).rejects.toThrow("Signing failed") + }) +}) diff --git a/packages/fcl-ethereum-provider/src/accounts/account-manager.ts b/packages/fcl-ethereum-provider/src/accounts/account-manager.ts index 4b41d83fb..a1fcbaf76 100644 --- a/packages/fcl-ethereum-provider/src/accounts/account-manager.ts +++ b/packages/fcl-ethereum-provider/src/accounts/account-manager.ts @@ -1,5 +1,6 @@ import * as fcl from "@onflow/fcl" -import {CurrentUser} from "@onflow/typedefs" +import * as rlp from "@onflow/rlp" +import {CompositeSignature, CurrentUser} from "@onflow/typedefs" import { ContractType, EVENT_IDENTIFIERS, @@ -9,6 +10,7 @@ import { FlowNetwork, } from "../constants" import {TransactionExecutedEvent} from "../types/events" +import {EthSignatureResponse} from "../types/eth" export class AccountManager { private user: typeof fcl.currentUser @@ -194,4 +196,45 @@ export class AccountManager { return evmTxHash } + + public async signMessage( + message: string, + from: string + ): Promise { + if (!this.coaAddress) { + throw new Error( + "COA address is not available. User might not be authenticated." + ) + } + + if (from.toLowerCase() !== this.coaAddress.toLowerCase()) { + throw new Error("Signer address does not match authenticated COA address") + } + + try { + const response: CompositeSignature[] = + await this.user.signUserMessage(message) + + if (!response || response.length === 0) { + throw new Error("Failed to sign message") + } + + const keyIndices = response.map(sig => sig.keyId) + const signatures = response.map(sig => sig.signature) + + const addressHexArray = Buffer.from(from.replace(/^0x/, ""), "hex") + + const capabilityPath = "/public/evm" + + const rlpEncodedProof = rlp + .encode([keyIndices, addressHexArray, capabilityPath, signatures]) + .toString("hex") + + return rlpEncodedProof.startsWith("0x") + ? rlpEncodedProof + : `0x${rlpEncodedProof}` // Return 0x-prefix for Ethereum compatibility + } catch (error) { + throw error + } + } } diff --git a/packages/fcl-ethereum-provider/src/rpc/handlers/personal-sign.ts b/packages/fcl-ethereum-provider/src/rpc/handlers/personal-sign.ts new file mode 100644 index 000000000..4b2d786c3 --- /dev/null +++ b/packages/fcl-ethereum-provider/src/rpc/handlers/personal-sign.ts @@ -0,0 +1,11 @@ +import {AccountManager} from "../../accounts/account-manager" +import {PersonalSignParams} from "../../types/eth" + +export async function personalSign( + accountManager: AccountManager, + params: PersonalSignParams +) { + const [message, from] = params + + return await accountManager.signMessage(message, from) +} diff --git a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts index d42bf092f..b6fa22db1 100644 --- a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts +++ b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts @@ -5,6 +5,8 @@ import {AccountManager} from "../accounts/account-manager" import * as fcl from "@onflow/fcl" import {FLOW_CHAINS, FlowNetwork} from "../constants" import {ethSendTransaction} from "./handlers/eth-send-transaction" +import {personalSign} from "./handlers/personal-sign" +import {PersonalSignParams} from "../types/eth" export class RpcProcessor { constructor( @@ -26,6 +28,11 @@ export class RpcProcessor { return ethRequestAccounts(this.accountManager) case "eth_sendTransaction": return await ethSendTransaction(this.accountManager, params) + case "personal_sign": + return await personalSign( + this.accountManager, + params as PersonalSignParams + ) default: return await this.gateway.request({ chainId: eip155ChainId, diff --git a/packages/fcl-ethereum-provider/src/types/eth.ts b/packages/fcl-ethereum-provider/src/types/eth.ts new file mode 100644 index 000000000..4cd8076db --- /dev/null +++ b/packages/fcl-ethereum-provider/src/types/eth.ts @@ -0,0 +1,3 @@ +export type EthSignatureResponse = string + +export type PersonalSignParams = [string, string]