225 lines
7.1 KiB
JavaScript
225 lines
7.1 KiB
JavaScript
import { loadEncryptedUserSecrets, updateEncryptedUserSecrets } from './key-vault.js';
|
||
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
|
||
import { deriveArweaveWalletFromDeviceKey32 } from './sawd-v1.js';
|
||
|
||
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
|
||
const AR_TOPUP_URL = 'https://changenow.io/exchange?from=usd&to=ar&amount=10&fiatMode=true';
|
||
const WINSTON_PER_AR = 1_000_000_000_000n;
|
||
|
||
let arweaveLibPromise = null;
|
||
|
||
function normalizeGateway(rawGateway) {
|
||
const raw = String(rawGateway || '').trim();
|
||
if (!raw) return DEFAULT_ARWEAVE_GATEWAY;
|
||
let parsed;
|
||
try {
|
||
parsed = new URL(raw);
|
||
} catch {
|
||
throw new Error('Некорректный Arweave gateway URL');
|
||
}
|
||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||
throw new Error('Arweave gateway должен использовать http или https');
|
||
}
|
||
const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/+$/g, '');
|
||
return normalized || DEFAULT_ARWEAVE_GATEWAY;
|
||
}
|
||
|
||
function parseGatewayForArweaveInit(gateway) {
|
||
let parsed;
|
||
try {
|
||
parsed = new URL(gateway);
|
||
} catch {
|
||
throw new Error('Некорректный Arweave gateway URL');
|
||
}
|
||
const protocol = parsed.protocol.replace(':', '');
|
||
if (protocol !== 'http' && protocol !== 'https') {
|
||
throw new Error('Arweave gateway должен использовать http или https');
|
||
}
|
||
const port = parsed.port
|
||
? Number(parsed.port)
|
||
: (protocol === 'https' ? 443 : 80);
|
||
return {
|
||
protocol,
|
||
host: parsed.hostname,
|
||
port,
|
||
};
|
||
}
|
||
|
||
function parseArToWinston(amountAr) {
|
||
const raw = String(amountAr ?? '').trim().replace(',', '.');
|
||
const match = raw.match(/^(\d+)(?:\.(\d+))?$/);
|
||
if (!match) {
|
||
throw new Error('Сумма перевода должна быть числом');
|
||
}
|
||
|
||
const intPart = BigInt(match[1] || '0');
|
||
const frac = String(match[2] || '');
|
||
if (frac.length > 12) {
|
||
throw new Error('Слишком много знаков после запятой (максимум 12)');
|
||
}
|
||
const fracPadded = `${frac}${'0'.repeat(12 - frac.length)}`;
|
||
const winston = (intPart * WINSTON_PER_AR) + BigInt(fracPadded || '0');
|
||
if (winston <= 0n) {
|
||
throw new Error('Сумма перевода должна быть больше 0');
|
||
}
|
||
return winston.toString();
|
||
}
|
||
|
||
async function loadArweaveLib() {
|
||
if (!arweaveLibPromise) {
|
||
arweaveLibPromise = import('https://esm.sh/arweave@1.15.7?bundle');
|
||
}
|
||
return arweaveLibPromise;
|
||
}
|
||
|
||
function pickCachedWallet(secrets) {
|
||
const cached = secrets?.arweaveWallet;
|
||
if (!cached || typeof cached !== 'object') return null;
|
||
const derivation = String(cached.derivation || '').trim();
|
||
const address = String(cached.address || '').trim();
|
||
const owner = String(cached.owner || '').trim();
|
||
const jwk = cached.jwk;
|
||
if (derivation !== 'SAWD-v1' || !address || !owner || !jwk || typeof jwk !== 'object') {
|
||
return null;
|
||
}
|
||
if (!String(jwk.kty || '').trim() || !String(jwk.e || '').trim() || !String(jwk.n || '').trim() || !String(jwk.d || '').trim()) {
|
||
return null;
|
||
}
|
||
return {
|
||
derivation,
|
||
address,
|
||
owner,
|
||
jwk,
|
||
};
|
||
}
|
||
|
||
function safeStatus(onStatus, text) {
|
||
if (typeof onStatus !== 'function') return;
|
||
try {
|
||
onStatus(String(text || ''));
|
||
} catch {
|
||
// ignore callback errors
|
||
}
|
||
}
|
||
|
||
export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd, onStatus } = {}) {
|
||
const cleanLogin = String(login || '').trim();
|
||
const cleanPwd = String(storagePwd || '').trim();
|
||
if (!cleanLogin || !cleanPwd) {
|
||
throw new Error('Нет активной сессии для доступа к wallet.key');
|
||
}
|
||
|
||
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
|
||
const cached = pickCachedWallet(secrets);
|
||
if (cached) {
|
||
safeStatus(onStatus, 'Arweave-кошелёк загружен.');
|
||
return cached;
|
||
}
|
||
|
||
safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего device key. Это может занять немного времени.');
|
||
|
||
const storedDeviceKey = String(secrets?.deviceKey || '').trim();
|
||
if (!storedDeviceKey) {
|
||
throw new Error('На устройстве не найден device.key (wallet.key)');
|
||
}
|
||
|
||
const deviceKey32 = extractDeviceKey32FromStoredValue(storedDeviceKey);
|
||
let wallet;
|
||
try {
|
||
wallet = await deriveArweaveWalletFromDeviceKey32(deviceKey32);
|
||
} finally {
|
||
deviceKey32.fill(0);
|
||
}
|
||
|
||
const cachedWallet = {
|
||
derivation: wallet.derivation,
|
||
address: wallet.address,
|
||
owner: wallet.owner,
|
||
jwk: wallet.jwk,
|
||
createdAtMs: Date.now(),
|
||
};
|
||
|
||
await updateEncryptedUserSecrets(cleanLogin, cleanPwd, (nextSecrets) => ({
|
||
...nextSecrets,
|
||
arweaveWallet: cachedWallet,
|
||
}));
|
||
|
||
return {
|
||
derivation: cachedWallet.derivation,
|
||
address: cachedWallet.address,
|
||
owner: cachedWallet.owner,
|
||
jwk: cachedWallet.jwk,
|
||
};
|
||
}
|
||
|
||
export async function getArweaveBalance({ gateway, address }) {
|
||
const normalizedGateway = normalizeGateway(gateway);
|
||
const cleanAddress = String(address || '').trim();
|
||
if (!cleanAddress) {
|
||
throw new Error('Не указан адрес Arweave');
|
||
}
|
||
|
||
const url = `${normalizedGateway}/wallet/${encodeURIComponent(cleanAddress)}/balance`;
|
||
const response = await fetch(url, { method: 'GET' });
|
||
const rawText = (await response.text()).trim();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Не удалось получить баланс Arweave (${response.status} ${response.statusText})`);
|
||
}
|
||
if (!/^\d+$/.test(rawText)) {
|
||
throw new Error('Arweave gateway вернул некорректный баланс');
|
||
}
|
||
|
||
const winstonBig = BigInt(rawText);
|
||
const ar = Number(winstonBig) / Number(WINSTON_PER_AR);
|
||
return {
|
||
gateway: normalizedGateway,
|
||
winston: rawText,
|
||
ar,
|
||
};
|
||
}
|
||
|
||
export function formatAr(value, digits = 6) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return '0';
|
||
return n.toLocaleString('ru-RU', {
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: digits,
|
||
});
|
||
}
|
||
|
||
export function getArweaveTopupSiteUrl() {
|
||
return AR_TOPUP_URL;
|
||
}
|
||
|
||
export async function transferAr({ gateway, jwk, toAddress, amountAr }) {
|
||
const cleanTo = String(toAddress || '').trim();
|
||
if (!cleanTo) throw new Error('Не указан адрес получателя Arweave');
|
||
if (!jwk || typeof jwk !== 'object') throw new Error('Кошелёк Arweave не инициализирован');
|
||
|
||
const normalizedGateway = normalizeGateway(gateway);
|
||
const { host, port, protocol } = parseGatewayForArweaveInit(normalizedGateway);
|
||
const winston = parseArToWinston(amountAr);
|
||
|
||
const moduleRef = await loadArweaveLib();
|
||
const Arweave = moduleRef?.default || moduleRef;
|
||
const arweave = Arweave.init({ host, port, protocol });
|
||
|
||
const tx = await arweave.createTransaction(
|
||
{
|
||
target: cleanTo,
|
||
quantity: winston,
|
||
},
|
||
jwk,
|
||
);
|
||
await arweave.transactions.sign(tx, jwk);
|
||
const postResult = await arweave.transactions.post(tx);
|
||
|
||
return {
|
||
gateway: normalizedGateway,
|
||
id: tx.id,
|
||
status: Number(postResult?.status || 0),
|
||
statusText: String(postResult?.statusText || ''),
|
||
};
|
||
}
|