/
ikratkiy
/
Hyperliquid
Обзор
Документация
Войти
/
ikratkiy
/
Hyperliquid
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
src/shared/hyperliquid/unit.ts
759 строк
24 KB
ikratkiy
fix(unit): bind recovery to verified deposit identity
14 июл 2026, 21:52
14 июл 2026, 21:52
f680599
Код
Авторство
О чём код?
import { Buffer } from "buffer"; import * as bitcoin from "bitcoinjs-lib"; import { getAddress } from "viem"; import type { BitcoinScenarioNetwork } from "../bitcoin/api"; export type UnitDepositAddressResponse = { address?: string; signatures?: Record<string, string>; status?: string; error?: string; }; export type UnitDepositAddressClassification = | { status: "ok"; address: string; networkLabel: string; signatureCount: number; raw: UnitDepositAddressResponse; } | { status: "error"; reason: string; raw: UnitDepositAddressResponse; }; export type UnitGuardianConfirmationResult = | { status: "verified"; verificationMethod: "unit-guardian-signatures"; signatureCount: number; verifiedCount: number; raw: UnitDepositAddressResponse; } | { status: "failed"; reason: string; signatureCount: number; verifiedCount: number; raw: UnitDepositAddressResponse; }; type UnitGuardianNode = { nodeId: string; publicKey: string; }; const TESTNET_GUARDIAN_NODES: UnitGuardianNode[] = [ { nodeId: "node-1", publicKey: "04bab844e8620c4a1ec304df6284cd6fdffcde79b3330a7bffb1e4cecfee72d02a7c1f3a4415b253dc8d6ca2146db170e1617605cc8a4160f539890b8a24712152", }, { nodeId: "hl-node-testnet", publicKey: "04502d20a0d8d8aaea9395eb46d50ad2d8278c1b3a3bcdc200d531253612be23f5f2e9709bf3a3a50d1447281fa81aca0bf2ac2a6a3cb8a12978381d73c24bb2d9", }, { nodeId: "field-node", publicKey: "04e674a796ff01d6b74f4ee4079640729797538cdb4926ec333ce1bd18414ef7f22c1a142fd76dca120614045273f30338cd07d79bc99872c76151756aaec0f8e8", }, ]; const MAINNET_GUARDIAN_NODES: UnitGuardianNode[] = [ { nodeId: "unit-node", publicKey: "04dc6f89f921dc816aa69b687be1fcc3cc1d48912629abc2c9964e807422e1047e0435cb5ba0fa53cb9a57a9c610b4e872a0a2caedda78c4f85ebafcca93524061", }, { nodeId: "hl-node", publicKey: "048633ea6ab7e40cdacf37d1340057e84bb9810de0687af78d031e9b07b65ad4ab379180ab55075f5c2ebb96dab30d2c2fab49d5635845327b6a3c27d20ba4755b", }, { nodeId: "field-node", publicKey: "04ae2ab20787f816ea5d13f36c4c4f7e196e29e867086f3ce818abb73077a237f841b33ada5be71b83f4af29f333dedc5411ca4016bd52ab657db2896ef374ce99", }, ]; const guardianSignatureThreshold = 2; export type UnitOperationSummary = { operationId: string; opCreatedAt: string; asset: string; state: string; sourceTxHash: string; }; export type BitcoinSourceReference = { reference: string; txid: string; vout: number | null; }; export type UnitBitcoinDepositOperation = UnitOperationSummary & { sourceChain: "bitcoin"; destinationChain: "hyperliquid"; protocolAddress: string; sourceAddress: string; destinationAddress: string; sourceAmount: string; destinationFeeAmount: string; sweepFeeAmount: string; destinationTxHash: string; sourceTxConfirmations: number | null; destinationTxConfirmations: number | null; sourceTxId: string; sourceTxVout: number; sourceTxReference: string; }; export type UnitBitcoinDepositOperationLookup = | { status: "found"; operation: UnitBitcoinDepositOperation } | { status: "not-found"; reason: string } | { status: "ambiguous"; reason: string; matches: UnitBitcoinDepositOperation[] } | { status: "malformed-response"; reason: string; raw: unknown }; export type UnitBitcoinDepositFeeEstimate = { depositEta: string; depositFeeBtc: string; depositFeeRateSatsPerVb: number; depositFeeSats: number; depositSizeVBytes: number; }; type FetchLike = (url: string, init?: RequestInit) => Promise<Pick<Response, "ok" | "status" | "statusText" | "text">>; export type UnitClient = { getBtcDepositAddress: (hyperliquidAddress: string) => Promise<unknown>; getOperations: (hyperliquidAddress: string) => Promise<unknown>; getFees: () => Promise<unknown>; }; export function buildUnitDepositAddressUrl(unitApiUrl: string, hyperliquidAddress: string) { const base = unitApiUrl.replace(/\/+$/, ""); return `${base}/gen/bitcoin/hyperliquid/btc/${hyperliquidAddress.toLowerCase()}`; } export function buildUnitOperationsUrl(unitApiUrl: string, hyperliquidAddress: string) { const base = unitApiUrl.replace(/\/+$/, ""); return `${base}/operations/${hyperliquidAddress.toLowerCase()}`; } export function buildUnitFeesUrl(unitApiUrl: string) { const base = unitApiUrl.replace(/\/+$/, ""); return `${base}/v2/estimate-fees`; } async function getUnitJson(unitApiUrl: string, fetcher: FetchLike) { const response = await fetcher(unitApiUrl); const text = await response.text(); const data = text ? JSON.parse(text) : null; if (!response.ok) { throw new Error(`Unit API HTTP ${response.status}: ${text || response.statusText}`); } return data; } export function createUnitClient(unitApiUrl: string, fetcher: FetchLike = fetch): UnitClient { return { getBtcDepositAddress: (hyperliquidAddress) => getUnitJson(buildUnitDepositAddressUrl(unitApiUrl, hyperliquidAddress), fetcher), getOperations: (hyperliquidAddress) => getUnitJson(buildUnitOperationsUrl(unitApiUrl, hyperliquidAddress), fetcher), getFees: () => getUnitJson(buildUnitFeesUrl(unitApiUrl), fetcher), }; } export function classifyUnitDepositAddress( raw: UnitDepositAddressResponse, options: { bitcoinNetwork?: BitcoinScenarioNetwork } = {}, ): UnitDepositAddressClassification { if (raw.status !== "OK" || !raw.address) { return { status: "error", reason: raw.error || "Unit did not return an OK BTC deposit address.", raw }; } const network = options.bitcoinNetwork ?? "signet"; if (network === "mainnet") { if (!raw.address.startsWith("bc1")) { return { status: "error", reason: "Unit returned a non-mainnet Bitcoin address.", raw }; } if (!isValidBitcoinAddress(raw.address, "bc")) { return { status: "error", reason: "Unit returned an invalid Bitcoin mainnet address or checksum.", raw }; } return { status: "ok", address: raw.address, networkLabel: "Bitcoin mainnet address returned by Unit", signatureCount: Object.keys(raw.signatures ?? {}).length, raw, }; } if (!raw.address.startsWith("tb1")) { return { status: "error", reason: "Unit returned a non-testnet-family Bitcoin address.", raw }; } if (!isValidBitcoinAddress(raw.address, "tb")) { return { status: "error", reason: "Unit returned an invalid Bitcoin testnet-family address or checksum.", raw }; } return { status: "ok", address: raw.address, networkLabel: "Bitcoin testnet address returned by Unit", signatureCount: Object.keys(raw.signatures ?? {}).length, raw, }; } function isValidBitcoinAddress(address: string, expectedPrefix: "bc" | "tb") { try { const decoded = bitcoin.address.fromBech32(address); const validWitnessProgramLength = decoded.data.length >= 2 && decoded.data.length <= 40; const validV0Length = decoded.version !== 0 || decoded.data.length === 20 || decoded.data.length === 32; return decoded.prefix === expectedPrefix && decoded.version >= 0 && decoded.version <= 16 && validWitnessProgramLength && validV0Length; } catch { return false; } } export async function verifyUnitGuardianSignatures( raw: UnitDepositAddressResponse, options?: { bitcoinNetwork?: BitcoinScenarioNetwork; destinationAddress?: string; destinationChain?: string }, ): Promise<UnitGuardianConfirmationResult> { const signatureCount = Object.keys(raw.signatures ?? {}).length; if (raw.status !== "OK" || !raw.address) { return { status: "failed", reason: raw.error || "Unit did not return an OK BTC deposit address.", signatureCount, verifiedCount: 0, raw, }; } const signatures = raw.signatures ?? {}; const guardianNodes = options?.bitcoinNetwork === "mainnet" ? MAINNET_GUARDIAN_NODES : TESTNET_GUARDIAN_NODES; const knownNodeIds = new Set(guardianNodes.map((node) => node.nodeId)); const unknownNodeIds = Object.keys(signatures).filter((nodeId) => !knownNodeIds.has(nodeId)); if (unknownNodeIds.length > 0) { return { status: "failed", reason: `Unit вернул подписи неизвестных guardians для выбранной сети: ${unknownNodeIds.join(", ")}.`, signatureCount, verifiedCount: 0, raw, }; } if (!options?.destinationAddress) { return { status: "failed", reason: "Для проверки подписей Unit нужен EVM-адрес получателя Hyperliquid.", signatureCount, verifiedCount: 0, raw, }; } if (!signatureCount) { return { status: "failed", reason: "Unit не вернул guardian signatures для BTC-адреса.", signatureCount, verifiedCount: 0, raw, }; } const subtle = globalThis.crypto?.subtle; if (!subtle) { return { status: "failed", reason: "WebCrypto недоступен, поэтому невозможно проверить Unit guardian signatures.", signatureCount, verifiedCount: 0, raw, }; } try { const destinationAddress = getAddress(options.destinationAddress); const destinationChain = options.destinationChain ?? "hyperliquid"; const verificationDetails: Record<string, boolean> = {}; let verifiedCount = 0; await Promise.all( guardianNodes.map(async (node) => { const signature = signatures[node.nodeId]; if (!signature) { verificationDetails[node.nodeId] = false; return; } const publicKey = await subtle.importKey("raw", hexToBytes(node.publicKey), { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]); const legacyPayload = legacyProposalToPayload(node.nodeId, { address: raw.address!, asset: "btc", destinationAddress, destinationChain, sourceChain: "bitcoin", }); const newPayload = newProposalToPayload(node.nodeId, { address: raw.address!, coinType: "bitcoin", destinationAddress, destinationChain, }); const verified = (await verifyGuardianSignature(publicKey, legacyPayload, signature)) || (await verifyGuardianSignature(publicKey, newPayload, signature)); verificationDetails[node.nodeId] = verified; if (verified) verifiedCount += 1; }), ); if (verifiedCount >= guardianSignatureThreshold) { return { status: "verified", verificationMethod: "unit-guardian-signatures", signatureCount, verifiedCount, raw, }; } return { status: "failed", reason: `Unit guardian signatures не прошли threshold ${guardianSignatureThreshold}/${guardianNodes.length}: подтверждено ${verifiedCount}.`, signatureCount, verifiedCount, raw, }; } catch (error) { return { status: "failed", reason: error instanceof Error ? error.message : "Не удалось проверить Unit guardian signatures.", signatureCount, verifiedCount: 0, raw, }; } } export function summarizeUnitOperations(raw: unknown): UnitOperationSummary[] { const operations = getUnitOperations(raw); if (!operations) { return []; } return operations .map(toBitcoinDepositOperation) .filter((operation): operation is UnitBitcoinDepositOperation => operation !== null) .map((operation) => ({ operationId: operation.operationId, opCreatedAt: operation.opCreatedAt, asset: operation.asset, state: operation.state, sourceTxHash: operation.sourceTxHash, })); } export function findBitcoinDepositOperation( raw: unknown, options: { sourceTxHash: string; asset: string; protocolAddress: string; destinationAddress: string }, ): UnitBitcoinDepositOperationLookup { const operations = getUnitOperations(raw); if (!operations) { return { status: "malformed-response", reason: "Ответ Unit operations не содержит массив operations.", raw, }; } const requestedReference = parseBitcoinSourceReference(options.sourceTxHash); if (!requestedReference) { return { status: "malformed-response", reason: "Bitcoin transaction id должен быть 64-значной hexadecimal строкой без 0x; Unit reference может дополнительно содержать :vout.", raw, }; } const routeRecords = operations.filter((operation) => isRawOperationForRoute(operation, options)); const parsedRouteRecords = routeRecords.map(toBitcoinDepositOperation); if (parsedRouteRecords.some((operation) => operation === null)) { return { status: "malformed-response", reason: "Unit вернул BTC -> Hyperliquid operation с неполной идентичностью, неверным txid:vout или opCreatedAt.", raw, }; } const matches = (parsedRouteRecords as UnitBitcoinDepositOperation[]).filter( (candidate) => candidate.sourceTxId === requestedReference.txid && (requestedReference.vout === null || candidate.sourceTxVout === requestedReference.vout), ); if (matches.length === 0) { return { status: "not-found", reason: `Операция Unit для BTC-транзакции ${options.sourceTxHash} не найдена.`, }; } if (matches.length > 1) { return { status: "ambiguous", reason: `Unit вернул несколько выходов для BTC-транзакции ${requestedReference.txid}; укажите точный txid:vout.`, matches, }; } return { status: "found", operation: matches[0], }; } export function extractBitcoinTxId(sourceTxHash: string) { return parseBitcoinSourceReference(sourceTxHash)?.txid ?? ""; } export function parseBitcoinSourceReference(sourceTxHash: string): BitcoinSourceReference | null { const normalized = sourceTxHash.trim(); const match = /^([0-9a-fA-F]{64})(?::([0-9]+))?$/.exec(normalized); if (!match) { return null; } const vout = match[2] === undefined ? null : Number(match[2]); if (vout !== null && (!Number.isSafeInteger(vout) || vout < 0 || vout > 0xffff_ffff)) { return null; } const txid = match[1].toLowerCase(); return { reference: vout === null ? txid : `${txid}:${vout}`, txid, vout, }; } export function isUnitOperationDone(operationOrState: UnitOperationStateLike): boolean { return readUnitOperationState(operationOrState).trim() === "done"; } export function formatUnitOperationState(operationOrState: UnitOperationStateLike): string { const state = readUnitOperationState(operationOrState).trim(); const normalized = normalizeComparable(state); if (!normalized) { return "Операция Unit не найдена."; } if (state === "done") { return `Готово: Unit зачислил ${readUnitOperationAsset(operationOrState)} на Hyperliquid.`; } if (normalized === "sourcetxdiscovered" || normalized === "srctxdiscovered") { return "Unit увидел BTC-транзакцию и ждёт финализацию перед зачислением в Hyperliquid."; } if (normalized === "waitforsrctxfinalization") { return "Ожидаем подтверждения BTC-транзакции в сети Bitcoin."; } if (normalized === "waitfordsttxfinalization") { return "Ожидаем зачисления операции Unit на Hyperliquid."; } if (unitInProgressStates.has(normalized)) { return `Unit обрабатывает операцию. Статус: ${state}.`; } if (unitFailedStates.has(normalized)) { return `Unit сообщил ошибку операции: ${state}.`; } return `Статус Unit: ${state}.`; } export function extractUnitBitcoinDepositFees(raw: unknown): UnitBitcoinDepositFeeEstimate { const bitcoin = getRecord(getRecord(raw).bitcoin); const depositFeeSats = positiveNumber(bitcoin.depositFee, "Unit bitcoin.depositFee"); const depositFeeRateSatsPerVb = positiveNumber(bitcoin["deposit-fee-rate-sats-per-vb"], "Unit bitcoin.deposit-fee-rate-sats-per-vb"); const depositSizeVBytes = positiveNumber(bitcoin["deposit-size-v-bytes"], "Unit bitcoin.deposit-size-v-bytes"); const depositEta = typeof bitcoin.depositEta === "string" && bitcoin.depositEta.trim() ? bitcoin.depositEta : "—"; return { depositEta, depositFeeBtc: formatSatsAsBtc(depositFeeSats), depositFeeRateSatsPerVb, depositFeeSats, depositSizeVBytes, }; } type UnitOperationStateLike = Pick<UnitOperationSummary, "state"> & Partial<Pick<UnitOperationSummary, "asset">> | string | null | undefined; const unitInProgressStates = new Set(["created", "pending", "processing", "inprogress", "submitted", "waitforsignatures", "sourcetxdiscovered", "srctxdiscovered"]); const unitFailedStates = new Set(["failed", "failure", "error", "rejected", "cancelled", "canceled"]); function getUnitOperations(raw: unknown): unknown[] | null { if (typeof raw !== "object" || raw === null || !("operations" in raw)) { return null; } const operations = (raw as { operations: unknown }).operations; return Array.isArray(operations) ? operations : null; } function toBitcoinDepositOperation(operation: unknown): UnitBitcoinDepositOperation | null { if (typeof operation !== "object" || operation === null) { return null; } const record = operation as Record<string, unknown>; if (normalizeComparable(record.sourceChain) !== "bitcoin" || normalizeComparable(record.destinationChain) !== "hyperliquid") { return null; } const sourceReference = parseBitcoinSourceReference(String(record.sourceTxHash ?? "")); const operationId = String(record.operationId ?? "").trim(); const opCreatedAt = String(record.opCreatedAt ?? "").trim(); const protocolAddress = String(record.protocolAddress ?? "").trim(); const destinationAddress = String(record.destinationAddress ?? "").trim(); if ( !sourceReference || sourceReference.vout === null || !operationId || !isStrictRfc3339DateTime(opCreatedAt) || !protocolAddress || !destinationAddress ) { return null; } return { operationId, opCreatedAt, asset: String(record.asset ?? ""), state: String(record.state ?? ""), sourceTxHash: sourceReference.reference, sourceChain: "bitcoin", destinationChain: "hyperliquid", protocolAddress, sourceAddress: String(record.sourceAddress ?? ""), destinationAddress, sourceAmount: String(record.sourceAmount ?? ""), destinationFeeAmount: String(record.destinationFeeAmount ?? ""), sweepFeeAmount: String(record.sweepFeeAmount ?? ""), destinationTxHash: String(record.destinationTxHash ?? ""), sourceTxConfirmations: readNonNegativeInteger(record.sourceTxConfirmations), destinationTxConfirmations: readNonNegativeInteger(record.destinationTxConfirmations), sourceTxId: sourceReference.txid, sourceTxVout: sourceReference.vout, sourceTxReference: sourceReference.reference, }; } function isRawOperationForRoute( operation: unknown, options: { asset: string; protocolAddress: string; destinationAddress: string }, ) { if (typeof operation !== "object" || operation === null) { return false; } const record = operation as Record<string, unknown>; return ( normalizeComparable(record.sourceChain) === "bitcoin" && normalizeComparable(record.destinationChain) === "hyperliquid" && normalizeComparable(record.asset) === normalizeComparable(options.asset) && normalizeComparable(record.protocolAddress) === normalizeComparable(options.protocolAddress) && normalizeComparable(record.destinationAddress) === normalizeComparable(options.destinationAddress) ); } function readNonNegativeInteger(value: unknown) { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { return null; } return value; } function isStrictRfc3339DateTime(value: string) { const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(value); if (!match) { return false; } const [, yearText, monthText, dayText, hourText, minuteText, secondText, offsetHourText, offsetMinuteText] = match; const year = Number(yearText); const month = Number(monthText); const day = Number(dayText); const hour = Number(hourText); const minute = Number(minuteText); const second = Number(secondText); const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText); const offsetMinute = offsetMinuteText === undefined ? 0 : Number(offsetMinuteText); const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0; return ( day >= 1 && day <= daysInMonth && hour <= 23 && minute <= 59 && second <= 59 && offsetHour <= 23 && offsetMinute <= 59 && Number.isFinite(Date.parse(value)) ); } function legacyProposalToPayload( nodeId: string, proposal: { address: string; asset: string; destinationAddress: string; destinationChain: string; sourceChain: string; }, ) { return new TextEncoder().encode( `${nodeId}:${[ proposal.destinationAddress, proposal.destinationChain, proposal.asset, proposal.address, proposal.sourceChain, "deposit", ].join("-")}`, ); } function newProposalToPayload( nodeId: string, proposal: { address: string; coinType: string; destinationAddress: string; destinationChain: string; }, ) { return new TextEncoder().encode(`${nodeId}:${["user", proposal.coinType, proposal.destinationChain, proposal.destinationAddress, proposal.address].join("-")}`); } async function verifyGuardianSignature(publicKey: CryptoKey, message: Uint8Array, signature: string) { let signatureBytes: Uint8Array; try { signatureBytes = base64ToBytes(signature); } catch { return false; } if (signatureBytes.length !== 64) { return false; } const signatureBuffer = signatureBytes.buffer.slice(signatureBytes.byteOffset, signatureBytes.byteOffset + signatureBytes.byteLength) as ArrayBuffer; const messageBuffer = message.buffer.slice(message.byteOffset, message.byteOffset + message.byteLength) as ArrayBuffer; return globalThis.crypto.subtle.verify( { name: "ECDSA", hash: { name: "SHA-256" }, }, publicKey, signatureBuffer, messageBuffer, ); } function hexToBytes(hex: string) { const cleanHex = hex.startsWith("0x") ? hex.slice(2) : hex; const bytes = new Uint8Array(cleanHex.length / 2); for (let index = 0; index < cleanHex.length; index += 2) { bytes[index / 2] = Number.parseInt(cleanHex.slice(index, index + 2), 16); } return bytes; } function base64ToBytes(value: string) { if (typeof globalThis.atob === "function") { return Uint8Array.from(globalThis.atob(value), (character) => character.charCodeAt(0)); } return Uint8Array.from(Buffer.from(value, "base64")); } function getRecord(value: unknown): Record<string, unknown> { if (typeof value !== "object" || value === null) { return {}; } return value as Record<string, unknown>; } function normalizeComparable(value: unknown) { return String(value ?? "") .trim() .toLowerCase(); } function readUnitOperationState(operationOrState: UnitOperationStateLike) { if (typeof operationOrState === "string") { return operationOrState; } return operationOrState?.state ?? ""; } function readUnitOperationAsset(operationOrState: UnitOperationStateLike) { if (typeof operationOrState === "string") { return "актив"; } const asset = operationOrState?.asset?.trim(); return asset ? asset.toUpperCase() : "актив"; } function positiveNumber(value: unknown, label: string) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { throw new Error(`${label} is missing from Unit fee estimate.`); } return parsed; } function formatSatsAsBtc(sats: number) { return (sats / 100_000_000).toFixed(8).replace(/\.?0+$/, "") || "0"; }