Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions lib/airdrop/siwe-verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { SiweMessage } from "siwe";
import { privateKeyToAccount } from "viem/accounts";

const TEST_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const account = privateKeyToAccount(TEST_KEY);

beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "");
vi.unstubAllEnvs();
vi.stubEnv("NEXT_PUBLIC_AIRDROP_MODE", "");
});

function buildMessage(overrides: Partial<SiweMessage> = {}): SiweMessage {
return new SiweMessage({
domain: "plotlink.xyz",
address: account.address,
statement: "PlotLink Buy-Back Sprint activation",
uri: "https://plotlink.xyz/airdrop",
version: "1",
chainId: 8453,
nonce: "abcd1234",
issuedAt: new Date().toISOString(),
...overrides,
});
}

async function signMessage(msg: SiweMessage): Promise<string> {
const prepared = msg.prepareMessage();
return account.signMessage({ message: prepared });
}

describe("verifySiweRequest", () => {
it("accepts a valid signature within freshness window", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const msg = buildMessage();
const sig = await signMessage(msg);
const result = await verifySiweRequest(msg.prepareMessage(), sig);
expect(result).toEqual({ ok: true, address: account.address.toLowerCase() });
});

it("rejects an invalid signature", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const msg = buildMessage();
const badSig = "0x" + "ab".repeat(65);
const result = await verifySiweRequest(msg.prepareMessage(), badSig);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe("invalid_signature");
});

it("rejects wrong domain", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const msg = buildMessage({ domain: "evil.com" });
const sig = await signMessage(msg);
const result = await verifySiweRequest(msg.prepareMessage(), sig);
expect(result).toEqual({ ok: false, error: "domain_mismatch" });
});

it("rejects wrong URI", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const msg = buildMessage({ uri: "https://evil.com/airdrop" });
const sig = await signMessage(msg);
const result = await verifySiweRequest(msg.prepareMessage(), sig);
expect(result).toEqual({ ok: false, error: "uri_mismatch" });
});

it("rejects wrong chain ID", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const msg = buildMessage({ chainId: 1 });
const sig = await signMessage(msg);
const result = await verifySiweRequest(msg.prepareMessage(), sig);
expect(result).toEqual({ ok: false, error: "chain_id_mismatch" });
});

it("rejects wrong statement", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const msg = buildMessage({ statement: "Wrong statement" });
const sig = await signMessage(msg);
const result = await verifySiweRequest(msg.prepareMessage(), sig);
expect(result).toEqual({ ok: false, error: "statement_mismatch" });
});

it("rejects expired signature (older than SIGNATURE_FRESHNESS_MIN)", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const old = new Date(Date.now() - 15 * 60_000);
const msg = buildMessage({ issuedAt: old.toISOString() });
const sig = await signMessage(msg);
const result = await verifySiweRequest(msg.prepareMessage(), sig);
expect(result).toEqual({ ok: false, error: "expired" });
});

it("rejects signature issued in the future", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const future = new Date(Date.now() + 5 * 60_000);
const msg = buildMessage({ issuedAt: future.toISOString() });
const sig = await signMessage(msg);
const result = await verifySiweRequest(msg.prepareMessage(), sig);
expect(result).toEqual({ ok: false, error: "issued_in_future" });
});

it("rejects message with past expirationTime", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const past = new Date(Date.now() - 60_000);
const msg = buildMessage({ expirationTime: past.toISOString() });
const sig = await signMessage(msg);
const result = await verifySiweRequest(msg.prepareMessage(), sig);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe("expired");
});

it("rejects unparseable message", async () => {
const { verifySiweRequest } = await import("./siwe-verify");
const result = await verifySiweRequest("not a siwe message", "0x1234");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe("invalid_message");
});
});
63 changes: 63 additions & 0 deletions lib/airdrop/siwe-verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { SiweMessage } from "siwe";
import { getAirdropConfig } from "./config";

export async function verifySiweRequest(
message: string,
signature: string,
): Promise<{ ok: true; address: string } | { ok: false; error: string }> {
const config = getAirdropConfig();

let parsed: SiweMessage;
try {
parsed = new SiweMessage(message);
} catch {
return { ok: false, error: "invalid_message" };
}

if (parsed.domain !== config.SIWE_DOMAIN) {
return { ok: false, error: "domain_mismatch" };
}
if (parsed.uri !== config.SIWE_URI) {
return { ok: false, error: "uri_mismatch" };
}
if (parsed.chainId !== config.SIWE_CHAIN_ID) {
return { ok: false, error: "chain_id_mismatch" };
}
if (parsed.statement !== config.SIWE_STATEMENT) {
return { ok: false, error: "statement_mismatch" };
}

if (parsed.issuedAt) {
const issuedAt = new Date(parsed.issuedAt);
const now = new Date();
const ageMin = (now.getTime() - issuedAt.getTime()) / 60_000;
if (ageMin > config.SIGNATURE_FRESHNESS_MIN) {
return { ok: false, error: "expired" };
}
if (ageMin < -1) {
return { ok: false, error: "issued_in_future" };
}
}

try {
const result = await parsed.verify(
{ signature, domain: config.SIWE_DOMAIN },
{ suppressExceptions: true },
);

if (!result.success) {
const errorType = result.error?.type ?? "unknown";
if (errorType.toLowerCase().includes("signature")) {
return { ok: false, error: "invalid_signature" };
}
if (errorType.toLowerCase().includes("expired")) {
return { ok: false, error: "expired" };
}
return { ok: false, error: errorType };
}

return { ok: true, address: result.data.address.toLowerCase() };
} catch {
return { ok: false, error: "invalid_signature" };
}
}
Loading
Loading