Skip to content

feat(wallets): add approval signature validation per signer type#1847

Open
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1779310288-wallets-approval-signature-validation
Open

feat(wallets): add approval signature validation per signer type#1847
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1779310288-wallets-approval-signature-validation

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Description

A third-party client used EVMWallet.signMessage() to sign the userOperationHash for a transaction approval. For undeployed smart wallets, signMessage() routes through the Signature API, which wraps the result with ERC-6492 (~580 bytes). The on-chain ECDSA validator expects a 65-byte raw ECDSA signature — it cannot parse the ERC-6492 envelope and reverts with InvalidSignature(), which surfaces as AA23.

The client assumption that signMessage(userOpHash) produces a valid transaction approval signature is incorrect — the SDK exposes wallet.approve() / wallet.send() specifically for this purpose.

This PR adds SDK-side validation inside wallet.approve() before calling the API, catching invalid signatures early with clear error messages:

  • ECDSA validators (external-wallet, server, email, phone): validates hex format via isHex(), rejects ERC-6492-wrapped signatures, checks 64/65-byte length via size(), and structurally parses 65-byte sigs via parseSignature()
  • P256 validator (device): validates r and s components satisfy 1 ≤ value < P256_ORDER
  • Passkey validator (passkey): delegates to P256 validation, then checks metadata presence
  • api-key: bypasses validation (auto-approved)
  • Unknown signer types: logs warning and skips (extensible via registerSignatureValidator())

Uses a generic SignatureValidator<TApproval> interface with typed approval aliases (EcdsaApproval, P256Approval, PasskeyApproval) for strong internal typing while the registry dispatches via the base Approval type.

Files changed:

  • packages/wallets/src/utils/signature-validation.ts — new validation module
  • packages/wallets/src/utils/signature-validation.test.ts — 45 test cases
  • packages/wallets/src/utils/errors.tsInvalidSignatureForApprovalError
  • packages/wallets/src/wallets/wallet.ts — integration at approveTransactionInternal and approveSignatureInternal

Test plan

  • 45 unit tests covering all validators across all signer types
  • ECDSA: valid 65-byte, valid 64-byte, non-hex rejection, ERC-6492 rejection, wrong byte length, structurally invalid sig, wrong shape (× 4 signer types)
  • P256: valid {r,s}, just below curve order, r=0, s=0, r≥ORDER, s≥ORDER
  • Passkey: valid with metadata, null metadata, missing metadata, p256 fires first on bad {r,s}
  • api-key bypass, unknown signer warning, cross-chain compatibility (EVM/Solana/Stellar), extensibility via registerSignatureValidator
  • All 363 existing tests continue to pass
  • Lint clean via Biome

Package updates

  • Added changeset for @crossmint/wallets-sdk (patch): SDK-side approval signature validation

Link to Devin session: https://crossmint.devinenterprise.com/sessions/5a13e5886299434b8ad992bb74a47901

daniil-dovgal and others added 2 commits May 20, 2026 20:51
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Original prompt from daniil.dovgal

// src/components/creator/TransferDialog.tsx
"use client";

import { useState } from "react";
import { useWallet, EVMWallet } from "@Crossmint Admin/client-sdk-react-ui";

type Props = {
productId: string | number;
currentOwnerEmail?: string;
};

export default function TransferDialog({ productId }: Props) {
const { wallet } = useWallet(); // Crossmint client SDK
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [address, setAddress] = useState("");
const [loading, setLoading] = useState(false);
const [resultUrl, setResultUrl] = useState<string | null>(null);

const validAddr = /^0x[a-fA-F0-9]{40}$/.test(address.trim());
const validEmail = !!email && /\S+@\S+.\S+/.test(email);
const canSubmit = (validEmail && !address) || (validAddr && !email);

async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit || loading) return;

setLoading(true);
setResultUrl(null);

console.groupCollapsed("[transferDialog] submit");
console.debug("[transferDialog] toEmail?", validEmail ? email : null);
console.debug("[transferDialog] toAddress?", validAddr ? address : null);

try {
  // 1) Crear en backend (Strapi)
  console.time("[transferDialog] POST /transfer");
  const r = await fetch(`/api/products/${productId}/transfer`, {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(
      validEmail ? { toUserEmail: email.trim() } : { toAddress: address.trim() }
    ),
  });
  console.timeEnd("[transferDialog] POST /transfer");

  const data: any = await r.json().catch(() =&gt; ({}));
  console.debug("[transferDialog] transfer response:", data);

  if (!r.ok || !data?.ok) {
    const msg =
      data?.error?.message ||
      data?.message ||
      (r.status === 401
        ? "No autenticado. Inicia sesión y vuelve a intentarlo... (8889 chars truncated...)

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 20, 2026

🦋 Changeset detected

Latest commit: 2b6f3ef

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@crossmint/wallets-sdk Patch
@crossmint/wallets-quickstart-devkit Patch
@crossmint/wallets-playground-react Patch
@crossmint/client-sdk-react-base Patch
@crossmint/client-sdk-react-native-ui Patch
@crossmint/client-sdk-react-ui Patch
@crossmint/wallets-playground-expo Patch
@crossmint/auth-ssr-nextjs-demo Patch
@crossmint/client-sdk-nextjs-starter Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 20, 2026

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/wallets/src/utils/signature-validation.ts:45
The ERC-6492 magic suffix comparison is case-sensitive (all lowercase), but `isHex()` from viem accepts mixed-case hex strings (`/^0x[0-9a-fA-F]*$/`). A signature whose suffix bytes happen to be uppercase — e.g., produced by a library that returns `toUpperCase()` hex — would silently pass this check and only be caught by the byte-length guard a few lines later, producing a generic "N bytes" error instead of the specific "ERC-6492 wrapped" message that helps callers diagnose the real issue. Normalizing to lowercase before the check ensures the specific error is always surfaced.

```suggestion
        if (signature.toLowerCase().endsWith(ERC_6492_MAGIC_SUFFIX)) {
```

### Issue 2 of 2
packages/wallets/src/utils/signature-validation.ts:109-111
**`registerSignatureValidator` silently overwrites built-in validators**

`Map.set` replaces any existing entry, so a caller can accidentally shadow core validators (e.g. `registerSignatureValidator("external-wallet", myValidator)`). The JSDoc says "Existing validators can be extended without modifying this module," implying the intent is additive, not override. Consider throwing if the key already exists in the built-in set, or at minimum logging a warning, so intentional overrides remain possible while accidental ones surface early.

Reviews (1): Last reviewed commit: "fix(wallets): use BigInt() instead of 0n..." | Re-trigger Greptile

);
}

if (signature.endsWith(ERC_6492_MAGIC_SUFFIX)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The ERC-6492 magic suffix comparison is case-sensitive (all lowercase), but isHex() from viem accepts mixed-case hex strings (/^0x[0-9a-fA-F]*$/). A signature whose suffix bytes happen to be uppercase — e.g., produced by a library that returns toUpperCase() hex — would silently pass this check and only be caught by the byte-length guard a few lines later, producing a generic "N bytes" error instead of the specific "ERC-6492 wrapped" message that helps callers diagnose the real issue. Normalizing to lowercase before the check ensures the specific error is always surfaced.

Suggested change
if (signature.endsWith(ERC_6492_MAGIC_SUFFIX)) {
if (signature.toLowerCase().endsWith(ERC_6492_MAGIC_SUFFIX)) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/wallets/src/utils/signature-validation.ts
Line: 45

Comment:
The ERC-6492 magic suffix comparison is case-sensitive (all lowercase), but `isHex()` from viem accepts mixed-case hex strings (`/^0x[0-9a-fA-F]*$/`). A signature whose suffix bytes happen to be uppercase — e.g., produced by a library that returns `toUpperCase()` hex — would silently pass this check and only be caught by the byte-length guard a few lines later, producing a generic "N bytes" error instead of the specific "ERC-6492 wrapped" message that helps callers diagnose the real issue. Normalizing to lowercase before the check ensures the specific error is always surfaced.

```suggestion
        if (signature.toLowerCase().endsWith(ERC_6492_MAGIC_SUFFIX)) {
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Easy there, cold-blooded one 🐍 — the ERC-6492 magic suffix is 6492649264926492... which is all digits (0-9). Digits don't have upper/lower case variants, so .toLowerCase() is a no-op here. After the isHex() guard, TypeScript narrows the signature to `0x${string}` with only valid hex chars — and since the suffix contains zero alphabetic hex characters (a-f/A-F), case sensitivity is irrelevant.

No change needed — the check is correct as-is. 🦎

Comment on lines +109 to +111
export function registerSignatureValidator(signerType: string, validator: SignatureValidator): void {
validatorRegistry.set(signerType, validator);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 registerSignatureValidator silently overwrites built-in validators

Map.set replaces any existing entry, so a caller can accidentally shadow core validators (e.g. registerSignatureValidator("external-wallet", myValidator)). The JSDoc says "Existing validators can be extended without modifying this module," implying the intent is additive, not override. Consider throwing if the key already exists in the built-in set, or at minimum logging a warning, so intentional overrides remain possible while accidental ones surface early.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/wallets/src/utils/signature-validation.ts
Line: 109-111

Comment:
**`registerSignatureValidator` silently overwrites built-in validators**

`Map.set` replaces any existing entry, so a caller can accidentally shadow core validators (e.g. `registerSignatureValidator("external-wallet", myValidator)`). The JSDoc says "Existing validators can be extended without modifying this module," implying the intent is additive, not override. Consider throwing if the key already exists in the built-in set, or at minimum logging a warning, so intentional overrides remain possible while accidental ones surface early.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lizard brain raises a fair point here 🦎 — but this is an intentional design choice. registerSignatureValidator is a public SDK extension point. Allowing overrides gives SDK consumers the flexibility to replace built-in validators with custom ones for their use case (e.g., a stricter ECDSA validator).

The JSDoc "extended without modifying this module" refers to adding new signer types — not a guarantee against overriding existing ones. Throwing on existing keys would break the Open/Closed extensibility pattern since there's no separate overrideSignatureValidator API.

That said, a console.warn when overwriting a built-in could be reasonable as a DX improvement — but that's a design decision for the maintainers. Leaving as-is for now and deferring to the reviewer. 🐊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant