китаев.tech

Alchemy Notify: вебхуки на транзакции

Как принять Alchemy Notify webhook, проверить подпись x-alchemy-signature и обработать событие транзакции в TypeScript.

Что присылает Alchemy Notify

Alchemy Notify отправляет HTTP webhook, когда происходит событие в сети: mined transaction, dropped transaction, activity по адресу или другое событие из Dashboard.

Webhook нельзя принимать как обычный JSON без проверки. Нужно валидировать x-alchemy-signature, иначе любой может отправить фальшивое событие на ваш endpoint.


Принимаем webhook и проверяем подпись

Создайте проект:

bash
mkdir alchemy-notify-webhook-demo
cd alchemy-notify-webhook-demo

npm init -y
npm pkg set type="module"
npm install --save-dev typescript tsx @types/node

mkdir src

Создайте файл src/server.ts:

typescript
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";

const HOST = "127.0.0.1";
const PORT = 8080;
const SIGNING_KEY = "whsec_local_demo_key";

type AlchemyWebhookEvent = {
  webhookId: string;
  id: string;
  createdAt: string;
  type: string;
  event: {
    network: string;
    transaction?: {
      hash: string;
      from: string;
      to: string | null;
      value: string;
    };
  };
};

function readRawBody(req: IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    let body = "";

    req.setEncoding("utf8");
    req.on("data", (chunk) => {
      body += chunk;
    });
    req.on("end", () => resolve(body));
    req.on("error", reject);
  });
}

function isValidAlchemySignature(rawBody: string, signature: string | undefined): boolean {
  if (!signature) {
    return false;
  }

  const digest = createHmac("sha256", SIGNING_KEY).update(rawBody).digest("hex");
  const expected = Buffer.from(digest, "hex");
  const actual = Buffer.from(signature, "hex");

  return expected.length === actual.length && timingSafeEqual(expected, actual);
}

function sendJson(res: ServerResponse, statusCode: number, data: unknown): void {
  res.writeHead(statusCode, { "content-type": "application/json" });
  res.end(JSON.stringify(data));
}

const server = createServer(async (req, res) => {
  if (req.method === "GET" && req.url === "/health") {
    sendJson(res, 200, { ok: true });
    return;
  }

  if (req.method !== "POST" || req.url !== "/alchemy-webhook") {
    sendJson(res, 404, { error: "not found" });
    return;
  }

  const rawBody = await readRawBody(req);
  const signature = req.headers["x-alchemy-signature"];
  const signatureValue = Array.isArray(signature) ? signature[0] : signature;

  if (!isValidAlchemySignature(rawBody, signatureValue)) {
    sendJson(res, 401, { error: "invalid signature" });
    return;
  }

  const payload = JSON.parse(rawBody) as AlchemyWebhookEvent;
  const transaction = payload.event.transaction;

  console.log("Webhook:", payload.type, payload.event.network);

  if (transaction) {
    console.log("Transaction:", transaction.hash);
    console.log("From:", transaction.from);
    console.log("To:", transaction.to);
    console.log("Value:", transaction.value);
  }

  sendJson(res, 200, { received: true, id: payload.id });
});

server.listen(PORT, HOST, () => {
  console.log(`Listening on http://${HOST}:${PORT}/alchemy-webhook`);
});

Создайте файл src/send-test-webhook.ts:

typescript
import { createHmac } from "node:crypto";

const SIGNING_KEY = "whsec_local_demo_key";
const WEBHOOK_URL = "http://127.0.0.1:8080/alchemy-webhook";

const payload = {
  webhookId: "wh_local_demo",
  id: "evt_local_demo",
  createdAt: new Date().toISOString(),
  type: "MINED_TRANSACTION",
  event: {
    network: "ETH_MAINNET",
    transaction: {
      hash: "0x5e3d0f6d8b3a2a6a3c0c8b4f1a1a1f0c9e9f6b2d8e0d2c1b0a9f8e7d6c5b4a3",
      from: "0x000000000000000000000000000000000000dEaD",
      to: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
      value: "1000000000000000000",
    },
  },
};

const rawBody = JSON.stringify(payload);
const signature = createHmac("sha256", SIGNING_KEY).update(rawBody).digest("hex");

const response = await fetch(WEBHOOK_URL, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-alchemy-signature": signature,
  },
  body: rawBody,
});

console.log(response.status, await response.text());

Запустите сервер:

bash
npx tsx src/server.ts

Во втором терминале отправьте тестовый webhook:

bash
npx tsx src/send-test-webhook.ts

Как это работает

readRawBody() — читает тело запроса строкой. Подпись нужно проверять именно по raw body, а не по уже распарсенному объекту.

createHmac("sha256", SIGNING_KEY) — считает HMAC так же, как Alchemy при отправке webhook.

x-alchemy-signature — заголовок с подписью от Alchemy.

timingSafeEqual() — сравнивает подписи без утечки времени сравнения.

payload.event.transaction — данные транзакции из события. Набор полей зависит от типа webhook в Alchemy Dashboard.

Частые ошибки

Парсите JSON до проверки подписи → меняется строковое представление body, и HMAC уже нельзя проверить корректно.

Отвечаете не 2xx после успешной обработки → Alchemy считает доставку неуспешной и может повторить webhook.

Храните signing key в коде → в реальном приложении положите его в переменную окружения и не коммитьте.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.