Skip to content

Commit bb91c04

Browse files
Fix MetaMask-mobile voting: ensure mainnet before signing the ballot (#16)
The ballot is EIP-712 signed with the domain pinned to mainnet (chainId 1). MetaMask refuses to sign typed data whose domain.chainId doesn't match the wallet's active chain. On desktop the wallet is already on mainnet so it's invisible, but MetaMask mobile over WalletConnect keeps whatever chain the user had open, so signing failed with the generic "Couldn't sign the ballot." castVote now switches the wallet to mainnet before signing (votingApi ensureSigningChain), re-reads the chain to catch wallets that ACK the switch but don't change, and throws WRONG_CHAIN otherwise. safeWalletErr surfaces a clear "Switch your wallet to Ethereum mainnet" message for both that sentinel and MetaMask's native chain-mismatch error. Zero impact on vote integrity: only the active network changes, never the signed payload or the domain, so every ballot already cast still verifies identically. Scoped to the vote path; the other signers (submit/delete) have the same latent bug and can reuse ensureSigningChain next. 4 regression tests: switches when on the wrong chain then signs, no switch on mainnet, refuses (no signature) when the switch fails, refuses when the switch silently doesn't change chains. Full suite 152/152. Co-authored-by: Xerxes <xerxes-openclaw@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8ad8e9b commit bb91c04

3 files changed

Lines changed: 90 additions & 2 deletions

File tree

src/app.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ function safeWalletErr(e, verb = "complete that") {
153153
const isReject = (e && e.name === "UserRejectedRequestError")
154154
|| /user rejected|user denied|rejected the request|request rejected|\b4001\b/i.test(raw);
155155
if (isReject) return "Signing cancelled.";
156+
// Wrong-chain: our own WRONG_CHAIN sentinel (votingApi.ensureSigningChain),
157+
// or the wallet's native "must match the active chainId" refusal when the
158+
// switch was skipped/unsupported. Common on MetaMask mobile via WalletConnect.
159+
const isWrongChain = /WRONG_CHAIN|must match the active chain|chain(?:id)? mismatch|provided chainid .* must match|switch.*(?:chain|network)|unsupported chain/i.test(raw);
160+
if (isWrongChain) return "Switch your wallet to Ethereum mainnet, then try again.";
156161
return `Couldn't ${verb}. Please try again.`;
157162
}
158163

src/votingApi.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,49 @@ export async function createProposal(
313313
* Sign + submit a ballot. Throws on validation failures (over-budget,
314314
* not a badgeholder, etc.) — caller renders the error.
315315
*/
316+
// MetaMask (and most wallets) refuse to sign EIP-712 typed data whose
317+
// domain.chainId doesn't match the wallet's ACTIVE chain — it throws
318+
// "provided chainId 1 must match the active chainId X". Our DOMAIN is pinned
319+
// to mainnet (chainId 1). On desktop the wallet is usually already on mainnet
320+
// so this is invisible, but MetaMask MOBILE over WalletConnect keeps whatever
321+
// chain the user had open, so signing silently failed there ("Couldn't sign
322+
// the ballot"). Put the wallet on mainnet before signing. This changes ONLY
323+
// the active network, never the signed payload or the domain, so every ballot
324+
// already cast still verifies bit-for-bit identically. Throws WRONG_CHAIN
325+
// (surfaced as a "switch to mainnet" message) if the wallet won't switch.
326+
async function ensureSigningChain(walletClient: WalletClient): Promise<void> {
327+
let active: number;
328+
try {
329+
active = await walletClient.getChainId();
330+
} catch {
331+
return; // can't read the chain; let the sign attempt surface the real error
332+
}
333+
if (active === mainnet.id) return;
334+
try {
335+
await walletClient.switchChain({ id: mainnet.id });
336+
} catch {
337+
throw new Error("WRONG_CHAIN: switch your wallet to Ethereum mainnet to vote.");
338+
}
339+
// Some mobile wallets ACK the switch but don't actually change chains —
340+
// re-read and refuse rather than produce a signature the wallet will reject.
341+
let after: number;
342+
try {
343+
after = await walletClient.getChainId();
344+
} catch {
345+
after = active;
346+
}
347+
if (after !== mainnet.id) {
348+
throw new Error("WRONG_CHAIN: switch your wallet to Ethereum mainnet to vote.");
349+
}
350+
}
351+
316352
export async function castVote(
317353
walletClient: WalletClient,
318354
voter: `0x${string}`,
319355
proposal: Proposal,
320356
allocations: Allocation[],
321357
): Promise<{ ok: true; voter: `0x${string}` }> {
358+
await ensureSigningChain(walletClient);
322359
const deadlineSec = Math.floor(new Date(proposal.deadline).getTime() / 1000);
323360
const ballot: Ballot = {
324361
voter,

tests/client/votingApi.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import * as api from "../../src/votingApi";
33

44
const ACTOR = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" as `0x${string}`;
5-
const wallet: any = { signTypedData: vi.fn(async () => "0xsignature" as `0x${string}`) };
5+
// Wallet already on mainnet (chainId 1) — the common desktop case; the chain
6+
// guard is a no-op here so these mirror pre-fix behaviour.
7+
const wallet: any = {
8+
signTypedData: vi.fn(async () => "0xsignature" as `0x${string}`),
9+
getChainId: vi.fn(async () => 1),
10+
switchChain: vi.fn(async () => {}),
11+
};
612

713
let calls: { url: string; init?: any }[] = [];
814
function mockFetch(responder: (url: string, init?: any) => any) {
@@ -17,7 +23,11 @@ const errJson = (status: number, body: any = {}) => ({ ok: false, status, json:
1723

1824
const proposal: any = { id: "r-1", title: "T", votingMode: "quadratic", budget: 100, options: [{ id: 1, label: "A" }], deadline: new Date(Date.now() + 86400000).toISOString() };
1925

20-
beforeEach(() => { wallet.signTypedData.mockClear(); });
26+
beforeEach(() => {
27+
wallet.signTypedData.mockClear();
28+
wallet.getChainId.mockClear().mockResolvedValue(1);
29+
wallet.switchChain.mockClear().mockResolvedValue(undefined);
30+
});
2131
afterEach(() => { vi.unstubAllGlobals(); });
2232

2333
describe("read endpoints", () => {
@@ -123,4 +133,40 @@ describe("write endpoints (sign + submit)", () => {
123133
mockFetch(() => errJson(400, { error: "over_budget" }));
124134
await expect(api.castVote(wallet, ACTOR, proposal, [{ issueId: 1, points: 99 }])).rejects.toThrow("castVote 400: over_budget");
125135
});
136+
137+
// Chain guard (MetaMask-mobile fix): the EIP-712 domain is pinned to mainnet,
138+
// so the wallet must be on chainId 1 before signing.
139+
it("castVote on the wrong chain switches to mainnet, then signs", async () => {
140+
// on Arbitrum first, on mainnet after the switch
141+
wallet.getChainId.mockResolvedValueOnce(42161).mockResolvedValueOnce(1);
142+
mockFetch(() => okJson({ ok: true, voter: ACTOR }));
143+
const r = await api.castVote(wallet, ACTOR, proposal, [{ issueId: 1, points: 5 }]);
144+
expect(wallet.switchChain).toHaveBeenCalledWith({ id: 1 });
145+
expect(wallet.signTypedData).toHaveBeenCalledOnce();
146+
expect(r.ok).toBe(true);
147+
});
148+
149+
it("castVote never switches when already on mainnet", async () => {
150+
wallet.getChainId.mockResolvedValue(1);
151+
mockFetch(() => okJson({ ok: true, voter: ACTOR }));
152+
await api.castVote(wallet, ACTOR, proposal, [{ issueId: 1, points: 5 }]);
153+
expect(wallet.switchChain).not.toHaveBeenCalled();
154+
});
155+
156+
it("castVote refuses (no signature) when the wallet won't switch", async () => {
157+
wallet.getChainId.mockResolvedValue(42161);
158+
wallet.switchChain.mockRejectedValue(new Error("unsupported"));
159+
mockFetch(() => okJson({ ok: true, voter: ACTOR }));
160+
await expect(api.castVote(wallet, ACTOR, proposal, [{ issueId: 1, points: 5 }])).rejects.toThrow(/mainnet/i);
161+
expect(wallet.signTypedData).not.toHaveBeenCalled();
162+
});
163+
164+
it("castVote refuses when the switch silently doesn't change chains", async () => {
165+
// wallet ACKs switchChain but stays on Arbitrum (mobile quirk)
166+
wallet.getChainId.mockResolvedValue(42161);
167+
wallet.switchChain.mockResolvedValue(undefined);
168+
mockFetch(() => okJson({ ok: true, voter: ACTOR }));
169+
await expect(api.castVote(wallet, ACTOR, proposal, [{ issueId: 1, points: 5 }])).rejects.toThrow(/mainnet/i);
170+
expect(wallet.signTypedData).not.toHaveBeenCalled();
171+
});
126172
});

0 commit comments

Comments
 (0)