unrip/src/core/intent-requests.mjs
philipp f34f27065a
All checks were successful
deploy / deploy (push) Successful in 33s
Implement NEAR Intents request creation flow
Proof: Adds repo-owned EURe-to-BTC request preflight, signing, gated live submission, durable request/result/outcome persistence, dashboard request lifecycle rows, and tests proving submitted/relay accepted are not completed without inventory movement.

Assumptions: The NEAR Intents solver relay quote, publish_intent, and get_status JSON-RPC methods accept signed raw_ed25519 token_diff payloads with quote_hashes; live validation remains bounded to 5 EUR per attempt, at most five attempts, and 200 bps slippage.

Still fake: Venue-native terminal fill linkage and fee-complete realized PnL are still unavailable; request completion is attributed from durable inventory deltas unless the venue later exposes a linked settlement id.
2026-04-12 18:43:40 +02:00

142 lines
5.2 KiB
JavaScript

const BPS_DENOMINATOR = 10_000n;
export function parseDecimalToUnits(value, decimals, { field = 'amount' } = {}) {
const raw = String(value ?? '').trim();
if (!raw) throw new Error(`${field} is required`);
if (!/^\d+(\.\d+)?$/.test(raw)) throw new Error(`${field} must be a positive decimal`);
const [whole, fraction = ''] = raw.split('.');
if (fraction.length > decimals) throw new Error(`${field} has too many decimal places`);
const paddedFraction = fraction.padEnd(decimals, '0');
const digits = `${whole}${paddedFraction}`.replace(/^0+/, '') || '0';
const units = BigInt(digits);
if (units <= 0n) throw new Error(`${field} must be greater than zero`);
return units.toString();
}
export function formatUnitsDecimal(units, decimals) {
const raw = String(units ?? '0');
const negative = raw.startsWith('-');
const digits = negative ? raw.slice(1) : raw;
const padded = digits.padStart(decimals + 1, '0');
const whole = padded.slice(0, padded.length - decimals) || '0';
const fraction = decimals > 0 ? padded.slice(-decimals).replace(/0+$/, '') : '';
return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`;
}
export function computeBtcReceiveUnitsFromEure({
eureUnits,
eurPerBtc,
eureDecimals,
btcDecimals,
}) {
const price = parsePositiveDecimal(eurPerBtc, { field: 'eur_per_btc' });
const sourceUnits = BigInt(String(eureUnits || '0'));
if (sourceUnits <= 0n) throw new Error('eureUnits must be greater than zero');
const numerator = sourceUnits * (10n ** BigInt(btcDecimals)) * price.scale;
const denominator = (10n ** BigInt(eureDecimals)) * price.units;
if (denominator <= 0n) throw new Error('invalid price denominator');
return (numerator / denominator).toString();
}
export function applySlippageBps(units, slippageBps) {
const parsed = Number(slippageBps);
if (!Number.isInteger(parsed) || parsed < 0 || parsed >= 10_000) {
throw new Error('slippage_bps must be an integer between 0 and 9999');
}
return ((BigInt(String(units || '0')) * (BPS_DENOMINATOR - BigInt(parsed))) / BPS_DENOMINATOR).toString();
}
export function buildSolverQuoteRequest({
sourceAssetId,
destinationAssetId,
sourceAmountUnits,
minDeadlineMs,
}) {
return {
defuse_asset_identifier_in: sourceAssetId,
defuse_asset_identifier_out: destinationAssetId,
exact_amount_in: String(sourceAmountUnits),
min_deadline_ms: Number(minDeadlineMs),
};
}
export function normalizeSolverQuotes(response) {
const quotes = Array.isArray(response)
? response
: Array.isArray(response?.quotes)
? response.quotes
: Array.isArray(response?.result)
? response.result
: response?.quote_hash || response?.hash
? [response]
: [];
return quotes
.map((quote) => {
const quoteHash = quote.quote_hash || quote.quoteHash || quote.hash || null;
const amountIn = quote.amount_in ?? quote.exact_amount_in ?? quote.amountIn ?? null;
const amountOut = quote.amount_out ?? quote.exact_amount_out ?? quote.amountOut ?? null;
if (!quoteHash || amountOut == null) return null;
return {
quote_hash: String(quoteHash),
amount_in: amountIn == null ? null : String(amountIn),
amount_out: String(amountOut),
expiration_time: quote.expiration_time || quote.expires_at || quote.expirationTime || null,
raw: quote,
};
})
.filter(Boolean);
}
export function selectBestSolverQuote(quotes, { minDestinationAmountUnits }) {
const minimum = BigInt(String(minDestinationAmountUnits || '0'));
let best = null;
for (const quote of quotes || []) {
const amountOut = BigInt(String(quote.amount_out || '0'));
if (amountOut < minimum) continue;
if (!best || amountOut > BigInt(String(best.amount_out || '0'))) best = quote;
}
return best;
}
export function normalizeRelayPublishResponse(response) {
const body = response && typeof response === 'object' ? response : { status: response };
const intentHash = body.intent_hash || body.intentHash || body.hash || body.tx_hash || null;
const relayStatus = body.status || body.result || (response === 'OK' ? 'OK' : null);
const accepted = response === 'OK'
|| relayStatus === 'OK'
|| relayStatus === 'PENDING'
|| relayStatus === 'TX_BROADCASTED'
|| relayStatus === 'SETTLED'
|| Boolean(intentHash);
return {
accepted,
intent_hash: intentHash ? String(intentHash) : null,
relay_status: relayStatus ? String(relayStatus) : null,
raw: response,
};
}
export function isExpired(timestamp, nowMs = Date.now()) {
const parsed = Date.parse(timestamp || '');
return Number.isFinite(parsed) && parsed <= nowMs;
}
export function futureIso(nowMs, durationMs) {
return new Date(nowMs + Number(durationMs || 0)).toISOString();
}
function parsePositiveDecimal(value, { field }) {
const raw = String(value ?? '').trim();
if (!/^\d+(\.\d+)?$/.test(raw)) throw new Error(`${field} must be a positive decimal`);
const [whole, fraction = ''] = raw.split('.');
const scale = 10n ** BigInt(fraction.length);
const units = BigInt(`${whole}${fraction}`.replace(/^0+/, '') || '0');
if (units <= 0n) throw new Error(`${field} must be greater than zero`);
return { units, scale };
}