linera-io/linera-protocol · error · Error

MetaMask signer does not expose a public key; EVM signatures

Error message

MetaMask signer does not expose a public key; EVM signatures carry the address

What it means

MetaMask produces EVM secp256k1 signatures that embed the signer's address (AccountSignature::EvmSecp256k1), so a 'public key' API is meaningless for it and always throws. The Linera wasm bridge never calls getPublicKey on the Address20 path; hitting this error means application code is calling the signer directly, bypassing the bridge contract.

Source

Thrown at web/@linera/metamask/src/signer.ts:87

      if (!signature) {
        throw new Error("No signature returned");
      }

      return signature;
    } catch (err: any) {
      throw new Error(
        `MetaMask signature request failed: ${err?.message || err}`,
      );
    }
  }

  async getPublicKey(_owner: string): Promise<string> {
    // MetaMask signs only for `Address20` (EVM secp256k1) owners. The wasm bridge
    // never calls `getPublicKey` on the Address20 path — EVM signatures carry the
    // signer's address inline in `AccountSignature::EvmSecp256k1`. If we get here,
    // a caller is reaching past the bridge contract.
    throw new Error(
      "MetaMask signer does not expose a public key; EVM signatures carry the address",
    );
  }

  async containsKey(owner: string): Promise<boolean> {
    const accounts = await this.provider.send("eth_requestAccounts", []);
    return accounts.some(
      (acc: string) => acc.toLowerCase() === owner.toLowerCase(),
    );
  }

  /**
   * Returns the currently connected MetaMask account address.
   */
  async address(): Promise<string> {
    const signer = await this.provider.getSigner();
    let address = await signer.getAddress();
    return address;

View on GitHub (pinned to 6c226ddcb3)

Solutions

  1. Never call getPublicKey on the MetaMask signer; guard calls by owner format (Address20) or by signer capability.
  2. If you need the signer identity, recover it from a signature (ethers.verifyMessage) — the address is the identity.
  3. Keep a capability map (signer -> supports getPublicKey) when iterating heterogeneous signers.

Example fix

// before
for (const s of signers) await s.getPublicKey(owner); // throws on MetaMask signer

// after
for (const s of signers) {
  if (s instanceof MetaMaskSigner) continue; // EVM sigs carry the address
  await s.getPublicKey(owner);
}
Defensive patterns

Strategy: type-guard

Validate before calling

const supportsPublicKey = (s: Signer): boolean => !(s instanceof MetaMaskSigner);
if (supportsPublicKey(signer)) await signer.getPublicKey(owner);

Type guard

function exposesPublicKey(signer: Signer): boolean {
  // Address20/EVM signers embed the address in signatures; they have no public-key API.
  return !(signer instanceof MetaMaskSigner);
}

Try / catch

try { await signer.getPublicKey(owner); }
catch (e) {
  if (e instanceof Error && e.message.includes('does not expose a public key')) { /* recover address from the signature instead */ }
  else throw e;
}

Prevention

When it happens

Trigger: Directly calling signer.getPublicKey(owner) on the MetaMask signer — e.g. generic code that enumerates all signers and calls getPublicKey on each, or reusing a public-key fetch routine written for the Ed25519 signer against every signer in a Composite.

Common situations: Refactoring owner/key inspection to treat all Signer implementations uniformly; tooling or debug panels that display public keys for every wallet; new callers assuming the SignerInterface contract is fully implemented by every signer.

Related errors


AI-assisted analysis of linera-io/linera-protocol@6c226ddcb3 (2026-08-22). Data as JSON: /api/errors/116d3670859d08d9. Report an issue: GitHub.