From 1578a2acc856383a3e419e433a2d5ae46d05dbda Mon Sep 17 00:00:00 2001 From: patnir Date: Fri, 19 Jun 2026 11:09:17 -0700 Subject: [PATCH] feat: pin signed SIWE message in coinbase claim route - verify the signature matches the wallet named in the message (handles smart wallets via viem) - pin the action to the one this airdrop gates on, so users cant swap it to mint a second token - shows what a third-party integrator should do instead of trusting the token blindly --- lib/verify-siwe-signature.ts | 101 +++++++++++++++++++++++++++++ pages/api/coinbase/verify-token.ts | 17 +++++ 2 files changed, 118 insertions(+) create mode 100644 lib/verify-siwe-signature.ts diff --git a/lib/verify-siwe-signature.ts b/lib/verify-siwe-signature.ts new file mode 100644 index 0000000..9ffb2de --- /dev/null +++ b/lib/verify-siwe-signature.ts @@ -0,0 +1,101 @@ +/** + * SIWE signature pinning for third-party integrators. + * + * Base Verify authenticates the *caller* (the app's secret key) and verifies the + * user's signature, but the eligibility rules live inside the user-signed SIWE + * message. A malicious user can edit that message before signing (drop a trait, + * swap the action) and still produce a valid signature. So a third-party app must + * bind the signed message back to what it actually intended before trusting the + * resulting token. + * + * This helper shows that discipline: + * 1. Confirm the signature really comes from the wallet named in the message. + * 2. Pin the action to exactly the one this app gates on. + * + * Trait pinning is handled separately by validateTraits(). + */ +import { SiweMessage } from "siwe"; +import { createPublicClient, http } from "viem"; +import { base, baseSepolia } from "viem/chains"; + +export interface SiweVerificationResult { + valid: boolean; + address?: string; + action?: string; + error?: string; +} + +// verifyMessage handles EOA, ERC-1271 and ERC-6492 (undeployed smart wallets), +// matching how the Base Verify backend verifies signatures. +function publicClientForChain(chainId: number) { + if (chainId === base.id) { + return createPublicClient({ chain: base, transport: http() }); + } + if (chainId === baseSepolia.id) { + return createPublicClient({ chain: baseSepolia, transport: http() }); + } + return undefined; +} + +function extractAction(resources: string[] | null | undefined): string | undefined { + if (!resources) return undefined; + for (const resource of resources) { + const match = resource.match(/^urn:verify:action:(.+)$/); + if (match) return match[1]; + } + return undefined; +} + +export async function verifySiweSignature( + message: string, + signature: string, + expectedAction: string +): Promise { + let parsed: SiweMessage; + try { + parsed = new SiweMessage(message); + } catch { + return { valid: false, error: "Malformed SIWE message" }; + } + + const address = parsed.address; + const action = extractAction(parsed.resources); + + // Pin the action. Without this, a user can swap the action to mint a different + // token for the same identity and bypass the one-claim-per-identity dedup. + if (action !== expectedAction) { + return { + valid: false, + address, + action, + error: `Action mismatch: expected '${expectedAction}', found '${action ?? "none"}'`, + }; + } + + const client = publicClientForChain(parsed.chainId); + if (!client) { + return { valid: false, address, action, error: `Unsupported chainId: ${parsed.chainId}` }; + } + + let signatureValid = false; + try { + signatureValid = await client.verifyMessage({ + address: address as `0x${string}`, + message, + signature: signature as `0x${string}`, + }); + } catch { + return { valid: false, address, action, error: "Signature verification call failed" }; + } + + if (!signatureValid) { + return { + valid: false, + address, + action, + error: "Signature does not match the wallet named in the message", + }; + } + + return { valid: true, address, action }; +} diff --git a/pages/api/coinbase/verify-token.ts b/pages/api/coinbase/verify-token.ts index 5c6534c..e95e6e3 100644 --- a/pages/api/coinbase/verify-token.ts +++ b/pages/api/coinbase/verify-token.ts @@ -2,6 +2,10 @@ import { NextApiRequest, NextApiResponse } from "next"; import { config } from "../../../lib/config"; import prisma from "../../../lib/prisma"; import { validateTraits } from "../../../lib/trait-validator"; +import { verifySiweSignature } from "../../../lib/verify-siwe-signature"; + +// The action this airdrop gates on. Must match what pages/coinbase.tsx signs. +const EXPECTED_ACTION = "claim_demo_coinbase_airdrop"; export default async function handler( req: NextApiRequest, @@ -41,6 +45,19 @@ export default async function handler( }); } + // Pin the signed message to what this app intended before trusting the token. + // Confirms the signature really comes from the wallet in the message and that + // the action is exactly the one this airdrop gates on. The trait pin above plus + // this check together stop a user from editing the message before signing. + const siweCheck = await verifySiweSignature(message, signature, EXPECTED_ACTION); + if (!siweCheck.valid) { + console.error("SIWE signature pinning failed:", siweCheck.error); + return res.status(400).json({ + error: "Signed message failed verification", + details: siweCheck.error, + }); + } + const requestBody: any = { signature, message,