Track nBTC reserve and legacy BTC asset
Some checks failed
deploy / deploy (push) Failing after 37s

Proof: npm test (138 passing); npm run operator-dashboard:build; git diff --cached --check.

Assumptions: Bridge deposits expose near_token_id/intents_token_id for credited asset attribution; nBTC is the solver trading reserve while btc.omft.near remains a tracked legacy BTC wrapper.

Still fake: No live asset migration was submitted; existing btc.omft.near balance is only tracked and withdrawable until a separately approved conversion or withdrawal is executed.
This commit is contained in:
philipp 2026-05-07 16:06:26 +02:00
parent 8507403b0f
commit ab078d976a
18 changed files with 597 additions and 73 deletions

View file

@ -10,12 +10,14 @@ data:
NEAR_INTENTS_RPC_URL: https://solver-relay-v2.chaindefuser.com/rpc
NEAR_INTENTS_BRIDGE_RPC_URL: https://bridge.chaindefuser.com/rpc
NEAR_INTENTS_VERIFIER_CONTRACT: intents.near
NEAR_RPC_URL: https://rpc.fastnear.com
NEAR_INTENTS_PAIR_FILTER: nep141:btc.omft.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near
NEAR_RPC_URL: https://near.lava.build
NEAR_INTENTS_PAIR_FILTER: nep141:nbtc.bridge.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near
NEAR_INTENTS_STATUS_POLL_MS: "60000"
NEAR_INTENTS_ACCOUNT_ID: unrip-dev.near
TRADING_BTC_ASSET_ID: nep141:btc.omft.near
TRADING_BTC_ASSET_ID: nep141:nbtc.bridge.near
TRADING_BTC_TRACKED_ASSET_IDS: nep141:nbtc.bridge.near,nep141:btc.omft.near
TRADING_BTC_SYMBOL: BTC
TRADING_BTC_LABEL: BTC / nBTC reserve
TRADING_BTC_DECIMALS: "8"
TRADING_BTC_CHAIN: btc:mainnet
TRADING_BTC_WITHDRAW_ADDRESS: ""

View file

@ -343,6 +343,7 @@ const controlApi = startControlApi({
async function refreshPortfolioMetrics() {
const inputs = await loadPortfolioMetricInputs(pool, {
btcAsset: config.tradingBtc,
btcAssets: config.tradingBtcAssets,
eureAsset: config.tradingEure,
});
const payload = computePortfolioMetric({
@ -351,6 +352,7 @@ async function refreshPortfolioMetrics() {
currentPrice: inputs.currentPrice?.payload,
externalFlows: inputs.externalFlows || [],
btcAsset: config.tradingBtc,
btcAssets: config.tradingBtcAssets,
eureAsset: config.tradingEure,
commandCount: inputs.commandCount,
resultCount: inputs.resultCount,

View file

@ -2,6 +2,10 @@ import process from 'node:process';
import { createConsumer } from '../bus/kafka/consumer.mjs';
import { createProducer } from '../bus/kafka/producer.mjs';
import {
bridgeDepositAssetId,
uniqueChainsForAssets,
} from '../core/bridge-assets.mjs';
import { startControlApi } from '../core/control-api.mjs';
import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs';
import { buildFundingVisibility } from '../core/funding-observations.mjs';
@ -43,6 +47,12 @@ const producer = await createProducer({
clientId: config.kafkaClientId,
logger,
});
const chains = uniqueChainsForAssets(config.trackedAssets);
const fallbackAssetByChain = new Map([
[config.tradingBtc.chain, config.tradingBtc.assetId],
[config.tradingEure.chain, config.tradingEure.assetId],
]);
const consumer = await createConsumer({
groupId: config.kafkaConsumerGroupInventory,
brokers: config.kafkaBrokers,
@ -117,23 +127,30 @@ async function refresh() {
try {
const balances = await verifierClient.mtBatchBalanceOf({
accountId: config.nearIntentsAccountId,
tokenIds: config.activeAssetIds,
tokenIds: config.trackedAssetIds,
});
const recentDeposits = [];
for (const chain of [config.tradingBtc.chain, config.tradingEure.chain]) {
for (const chain of chains) {
const response = await bridgeClient.recentDeposits({
accountId: config.nearIntentsAccountId,
chain,
});
for (const deposit of response?.deposits || []) {
const assetId = bridgeDepositAssetId(deposit, {
assetRegistry: config.assetRegistry,
fallbackAssetId: fallbackAssetByChain.get(chain),
});
recentDeposits.push({
tx_hash: deposit.tx_hash || null,
chain,
asset_id: chain === config.tradingBtc.chain ? config.tradingBtc.assetId : config.tradingEure.assetId,
asset_id: assetId,
amount: String(deposit.amount || '0'),
address: deposit.address,
status: deposit.status,
decimals: deposit.decimals,
near_token_id: deposit.near_token_id || null,
intents_token_id: deposit.intents_token_id || null,
defuse_asset_identifier: deposit.defuse_asset_identifier || null,
});
}
}

View file

@ -1,6 +1,11 @@
import process from 'node:process';
import { createProducer } from '../bus/kafka/producer.mjs';
import {
assetsByChain as groupAssetsByChain,
bridgeDepositAssetId,
uniqueChainsForAssets,
} from '../core/bridge-assets.mjs';
import { startControlApi } from '../core/control-api.mjs';
import { buildEventEnvelope } from '../core/event-envelope.mjs';
import {
@ -83,8 +88,9 @@ const store = createJsonStateStore({
},
});
const chains = [config.tradingBtc.chain, config.tradingEure.chain];
const assetsByChain = new Map([
const chains = uniqueChainsForAssets(config.trackedAssets);
const trackedAssetsByChain = groupAssetsByChain(config.trackedAssets);
const fallbackAssetByChain = new Map([
[config.tradingBtc.chain, config.tradingBtc.assetId],
[config.tradingEure.chain, config.tradingEure.assetId],
]);
@ -145,8 +151,11 @@ async function refreshChain(chain, state) {
action_type: 'deposit_address_refreshed',
status: 'READY',
chain,
asset_id: assetsByChain.get(chain),
details: depositAddress,
asset_id: chainAssetIds(chain).length === 1 ? chainAssetIds(chain)[0] : null,
details: {
...depositAddress,
asset_ids: chainAssetIds(chain),
},
}, state);
}
@ -156,8 +165,12 @@ async function refreshChain(chain, state) {
});
const bridgeDeposits = deposits?.deposits || [];
for (const deposit of bridgeDeposits) {
const key = `${chain}:${deposit.tx_hash || deposit.address}:${deposit.defuse_asset_identifier}`;
const assetId = mapDepositAssetId(deposit.defuse_asset_identifier, chain);
const assetId = mapDepositAssetId(deposit, chain);
const key = [
chain,
deposit.tx_hash || deposit.address,
deposit.intents_token_id || deposit.near_token_id || deposit.defuse_asset_identifier,
].join(':');
const normalized = {
tx_hash: deposit.tx_hash || null,
chain,
@ -167,6 +180,9 @@ async function refreshChain(chain, state) {
address: deposit.address,
status: deposit.status,
decimals: deposit.decimals,
near_token_id: deposit.near_token_id || null,
intents_token_id: deposit.intents_token_id || null,
defuse_asset_identifier: deposit.defuse_asset_identifier || null,
};
const previous = state.deposits[key];
state.deposits[key] = normalized;
@ -240,6 +256,11 @@ async function refreshFundingObservations({ chain, state, fundingHandle, bridgeD
try {
const observed = await observer.listTransactions({ address: fundingHandle });
for (const tx of observed.transactions) {
const bridgeDeposit = matchBridgeDeposit({
txHash: tx.tx_hash,
fundingHandle,
bridgeDeposits,
});
const key = buildFundingObservationKey({
chain,
fundingHandle,
@ -249,7 +270,9 @@ async function refreshFundingObservations({ chain, state, fundingHandle, bridgeD
const next = correlateFundingObservation({
existing: previous,
accountId: config.nearIntentsAccountId,
assetId: assetsByChain.get(chain),
assetId: bridgeDeposit
? mapDepositAssetId(bridgeDeposit, chain)
: fallbackAssetByChain.get(chain),
chain,
fundingHandle,
source: tx.source || observed.source,
@ -257,11 +280,7 @@ async function refreshFundingObservations({ chain, state, fundingHandle, bridgeD
amount: tx.amount,
confirmations: tx.confirmations,
observedAt: tx.observed_at || observed.observed_at,
bridgeDeposit: matchBridgeDeposit({
txHash: tx.tx_hash,
fundingHandle,
bridgeDeposits,
}),
bridgeDeposit,
stuckAfterMs: config.fundingObservationStuckMs,
});
state.funding_observations[key] = next;
@ -273,10 +292,15 @@ async function refreshFundingObservations({ chain, state, fundingHandle, bridgeD
for (const [key, previous] of Object.entries(state.funding_observations)) {
if (previous.chain !== chain || previous.funding_handle !== fundingHandle) continue;
const bridgeDeposit = matchBridgeDeposit({
txHash: previous.tx_hash,
fundingHandle,
bridgeDeposits,
});
const next = correlateFundingObservation({
existing: previous,
accountId: previous.account_id,
assetId: previous.asset_id,
assetId: bridgeDeposit ? mapDepositAssetId(bridgeDeposit, chain) : previous.asset_id,
chain: previous.chain,
fundingHandle: previous.funding_handle,
source: previous.source,
@ -284,11 +308,7 @@ async function refreshFundingObservations({ chain, state, fundingHandle, bridgeD
amount: previous.amount,
confirmations: previous.confirmations,
observedAt: refreshedAt,
bridgeDeposit: matchBridgeDeposit({
txHash: previous.tx_hash,
fundingHandle,
bridgeDeposits,
}),
bridgeDeposit,
stuckAfterMs: config.fundingObservationStuckMs,
});
state.funding_observations[key] = next;
@ -768,10 +788,15 @@ function mapSupportedTokens(tokens) {
);
}
function mapDepositAssetId(defuseAssetIdentifier, chain) {
if (chain === config.tradingBtc.chain) return config.tradingBtc.assetId;
if (chain === config.tradingEure.chain) return config.tradingEure.assetId;
return defuseAssetIdentifier;
function mapDepositAssetId(deposit, chain) {
return bridgeDepositAssetId(deposit, {
assetRegistry: config.assetRegistry,
fallbackAssetId: fallbackAssetByChain.get(chain),
});
}
function chainAssetIds(chain) {
return (trackedAssetsByChain.get(chain) || []).map((asset) => asset.assetId);
}
function inferWithdrawStatusCode(error) {
@ -796,10 +821,10 @@ function buildPublicState() {
return {
account_id: config.nearIntentsAccountId,
withdrawal_defaults: {
[config.tradingBtc.assetId]: config.tradingBtc.withdrawAddress || null,
[config.tradingEure.assetId]: config.tradingEure.withdrawAddress || null,
},
tracked_assets: config.trackedAssets,
withdrawal_defaults: Object.fromEntries(
config.trackedAssets.map((asset) => [asset.assetId, asset.withdrawAddress || null]),
),
...state,
observer_health: buildObserverHealth(state.observer_health, {
now,

View file

@ -0,0 +1,43 @@
export function intentsAssetIdFromNearTokenId(nearTokenId) {
const normalized = String(nearTokenId || '').trim();
if (!normalized) return null;
if (normalized.startsWith('nep141:')) return normalized;
return `nep141:${normalized}`;
}
export function bridgeDepositAssetId(deposit, {
assetRegistry = new Map(),
fallbackAssetId = null,
} = {}) {
const candidates = [
deposit?.intents_token_id,
intentsAssetIdFromNearTokenId(deposit?.near_token_id),
deposit?.defuse_asset_identifier,
fallbackAssetId,
].filter(Boolean);
for (const candidate of candidates) {
if (!assetRegistry?.size || assetRegistry.has(candidate)) return candidate;
}
return fallbackAssetId || candidates[0] || null;
}
export function uniqueChainsForAssets(assets = []) {
return [...new Set(
assets
.map((asset) => asset?.chain)
.filter(Boolean),
)];
}
export function assetsByChain(assets = []) {
const byChain = new Map();
for (const asset of assets) {
if (!asset?.chain) continue;
const chainAssets = byChain.get(asset.chain) || [];
chainAssets.push(asset);
byChain.set(asset.chain, chainAssets);
}
return byChain;
}

View file

@ -175,7 +175,8 @@ export function hasFundingObservationChanged(previous, next) {
if (!previous) return true;
return (
previous.status !== next.status
previous.asset_id !== next.asset_id
|| previous.status !== next.status
|| previous.confirmations !== next.confirmations
|| previous.bridge_deposit_tx_hash !== next.bridge_deposit_tx_hash
|| previous.bridge_status !== next.bridge_status

View file

@ -308,6 +308,7 @@ export function createDashboardLiveState({
config,
active_pair: config.activePair,
btc_asset: config.tradingBtc,
btc_assets: config.tradingBtcAssets || [config.tradingBtc],
eure_asset: config.tradingEure,
quote_limit: config.operatorDashboardQuoteLimit || DASHBOARD_LIVE_QUOTE_LIMIT,
lifecycle_limit: config.operatorDashboardLifecycleLimit || DASHBOARD_LIVE_LIFECYCLE_LIMIT,
@ -670,6 +671,7 @@ export function buildLiveStatusBar(state) {
inventory: state.latest_inventory,
marketPrice: state.latest_market_price,
btcAsset: state.btc_asset,
btcAssets: state.btc_assets,
eureAsset: state.eure_asset,
}),
active_alert_count: 0,
@ -728,6 +730,8 @@ function buildBalanceSummary({ inventorySnapshot, marketPrice, config }) {
return {
asset_id: asset.assetId,
symbol: asset.symbol,
label: asset.label || asset.symbol,
role: asset.role || null,
chain: asset.chain,
spendable_units: spendableUnits,
spendable: formatUnits(spendableUnits, asset.decimals),
@ -772,14 +776,24 @@ function buildFundingSummary({ config, fundingObservations, recentDepositStatuse
withdrawals_frozen: liquidityState?.withdrawals_frozen ?? null,
withdrawal_defaults: liquidityState?.withdrawal_defaults || {},
},
handles: Object.entries(liquidityState?.deposit_addresses || {}).map(([chain, details]) => ({
chain,
asset_id: config.tradingBtc.chain === chain ? config.tradingBtc.assetId : config.tradingEure.assetId,
symbol: config.tradingBtc.chain === chain ? config.tradingBtc.symbol : config.tradingEure.symbol,
address: details?.address || null,
memo: details?.memo || null,
refreshed_at: details?.refreshed_at || null,
})),
handles: Object.entries(liquidityState?.deposit_addresses || {}).map(([chain, details]) => {
const chainAssets = (config.trackedAssets || [...config.assetRegistry.values()])
.filter((asset) => asset.chain === chain);
return {
chain,
asset_id: chainAssets.length === 1 ? chainAssets[0].assetId : null,
asset_ids: chainAssets.map((asset) => asset.assetId),
symbol: chainAssets.length === 1
? chainAssets[0].symbol
: chainAssets.map((asset) => asset.label || asset.symbol).join(', '),
label: chainAssets.length === 1
? (chainAssets[0].label || chainAssets[0].symbol)
: `${chain} funding handle`,
address: details?.address || null,
memo: details?.memo || null,
refreshed_at: details?.refreshed_at || null,
};
}),
credited_deposits: recentFundingActivity
.filter((observation) => CREDITED_FUNDING_STATUSES.has(String(observation?.status || '').toUpperCase()))
.sort((left, right) => sortTimestamps(
@ -793,6 +807,7 @@ function buildFundingSummary({ config, fundingObservations, recentDepositStatuse
return {
asset_id: entry.asset_id,
symbol: asset?.symbol || entry.asset_id,
asset_symbol: asset?.label || asset?.symbol || entry.asset_id,
pre_credit_total_units: entry.pre_credit_total || '0',
pre_credit_total: formatUnits(entry.pre_credit_total || '0', asset?.decimals || 0),
latest_status: entry.latest_status,
@ -806,6 +821,7 @@ function buildFundingSummary({ config, fundingObservations, recentDepositStatuse
chain: entry.chain,
asset_id: entry.asset_id,
symbol: asset?.symbol || entry.asset_id,
asset_symbol: asset?.label || asset?.symbol || entry.asset_id,
pre_credit_total_units: entry.pre_credit_total || '0',
pre_credit_total: formatUnits(entry.pre_credit_total || '0', asset?.decimals || 0),
latest_status: entry.latest_status,
@ -882,11 +898,16 @@ function buildRecentWithdrawals({ config, liquidityState }) {
withdrawal_hash: withdrawal.withdrawal_hash,
asset_id: withdrawal.asset_id,
symbol: asset?.symbol || withdrawal.asset_id,
asset_symbol: asset?.label || asset?.symbol || withdrawal.asset_id,
chain: withdrawal.chain || null,
amount_units: String(withdrawal.amount || '0'),
amount_display: formatUnits(withdrawal.amount || '0', asset?.decimals || 0),
amount: formatUnits(withdrawal.amount || '0', asset?.decimals || 0),
status: withdrawal.status || null,
address: withdrawal.address || null,
destination_address: withdrawal.address || null,
requested_at: withdrawal.submitted_at || withdrawal.noted_at || null,
completed_at: withdrawal.status === 'COMPLETED' ? withdrawal.last_checked_at : null,
submitted_at: withdrawal.submitted_at || null,
last_checked_at: withdrawal.last_checked_at || null,
noted_at: withdrawal.noted_at || null,
@ -1849,11 +1870,13 @@ function normalizeFundingObservationForUi({ config, observation }) {
funding_observation_id: observation.funding_observation_id,
asset_id: observation.asset_id,
symbol: asset?.symbol || observation.asset_id,
asset_symbol: asset?.label || asset?.symbol || observation.asset_id,
chain: observation.chain,
funding_handle: observation.funding_handle,
tx_hash: observation.tx_hash,
status: observation.status,
amount_units: observation.amount,
amount_display: formatUnits(observation.amount || '0', asset?.decimals || 0),
amount: formatUnits(observation.amount || '0', asset?.decimals || 0),
confirmations: observation.confirmations,
first_seen_at: observation.first_seen_at,
@ -1888,11 +1911,13 @@ function normalizeLiquidityDepositForUi({ config, deposit, observedAt }) {
funding_observation_id: null,
asset_id: deposit?.asset_id || null,
symbol: asset?.symbol || deposit?.asset_id || null,
asset_symbol: asset?.label || asset?.symbol || deposit?.asset_id || null,
chain: deposit?.chain || null,
funding_handle: deposit?.address || null,
tx_hash: deposit?.tx_hash || null,
status,
amount_units: String(deposit?.amount || '0'),
amount_display: formatUnits(deposit?.amount || '0', asset?.decimals || 0),
amount: formatUnits(deposit?.amount || '0', asset?.decimals || 0),
confirmations: null,
first_seen_at: timestamp,
@ -2117,12 +2142,21 @@ function highestAlertSeverity(alerts) {
}, null);
}
function computeCurrentPortfolioValue({ inventory, marketPrice, btcAsset, eureAsset }) {
function computeCurrentPortfolioValue({
inventory,
marketPrice,
btcAsset,
btcAssets = null,
eureAsset,
}) {
if (!inventory || !marketPrice || !btcAsset || !eureAsset) return null;
const btcUnits = String(inventory.spendable?.[btcAsset.assetId] || '0');
const effectiveBtcAssets = btcAssets?.length ? btcAssets : [btcAsset];
const btcScaled = effectiveBtcAssets.reduce((total, asset) => {
const units = String(inventory.spendable?.[asset.assetId] || '0');
return total + unitsToScaledDecimal(units, asset.decimals);
}, 0n);
const eureUnits = String(inventory.spendable?.[eureAsset.assetId] || '0');
const btcScaled = unitsToScaledDecimal(btcUnits, btcAsset.decimals);
const eureScaled = unitsToScaledDecimal(eureUnits, eureAsset.decimals);
const priceScaled = parseScaledDecimal(marketPrice.eure_per_btc || marketPrice.eur_per_btc || '0');
const total = eureScaled + multiplyScaled(btcScaled, priceScaled);

View file

@ -1,7 +1,7 @@
import fs from 'node:fs';
export const DEFAULT_NEAR_INTENTS_PAIR_FILTER =
'nep141:btc.omft.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near';
'nep141:nbtc.bridge.near->nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near';
export function parsePairFilter(argv) {
const idx = argv.indexOf('--pair');

View file

@ -7,17 +7,20 @@ export function computePortfolioMetric({
currentPrice,
externalFlows = [],
btcAsset,
btcAssets = null,
eureAsset,
commandCount = 0,
resultCount = 0,
} = {}) {
if (!currentInventory || !currentPrice || !btcAsset?.assetId || !eureAsset?.assetId) {
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
const displayBtcAsset = effectiveBtcAssets[0];
if (!currentInventory || !currentPrice || !displayBtcAsset?.assetId || !eureAsset?.assetId) {
return null;
}
const currentBtcUnits = String(currentInventory.spendable?.[btcAsset.assetId] || '0');
const currentBtcUnits = sumAssetUnits(currentInventory, effectiveBtcAssets).toString();
const currentEureUnits = String(currentInventory.spendable?.[eureAsset.assetId] || '0');
const currentBtc = unitsToScaledDecimal(currentBtcUnits, btcAsset.decimals);
const currentBtc = sumAssetScaled(currentInventory, effectiveBtcAssets);
const currentEure = unitsToScaledDecimal(currentEureUnits, eureAsset.decimals);
const currentPriceScaled = parseScaledDecimal(currentPrice.eure_per_btc);
const currentBtcMarkValue = multiplyScaled(currentBtc, currentPriceScaled);
@ -35,7 +38,8 @@ export function computePortfolioMetric({
},
current_inventory: buildInventoryView({
inventory: currentInventory,
btcAsset,
btcAsset: displayBtcAsset,
btcAssets: effectiveBtcAssets,
eureAsset,
}),
current_portfolio_value_eure: formatScaledDecimal(currentPortfolioValue),
@ -70,9 +74,9 @@ export function computePortfolioMetric({
return payload;
}
const baselineBtcUnits = String(baseline.inventory.spendable?.[btcAsset.assetId] || '0');
const baselineBtcUnits = sumAssetUnits(baseline.inventory, effectiveBtcAssets).toString();
const baselineEureUnits = String(baseline.inventory.spendable?.[eureAsset.assetId] || '0');
const baselineBtc = unitsToScaledDecimal(baselineBtcUnits, btcAsset.decimals);
const baselineBtc = sumAssetScaled(baseline.inventory, effectiveBtcAssets);
const baselineEure = unitsToScaledDecimal(baselineEureUnits, eureAsset.decimals);
const baselinePriceScaled = parseScaledDecimal(baseline.price.eure_per_btc);
const baselinePortfolioAtBaselinePrice = baselineEure + multiplyScaled(baselineBtc, baselinePriceScaled);
@ -81,7 +85,8 @@ export function computePortfolioMetric({
const externalFlowSummary = summarizeExternalFlows({
externalFlows,
currentPriceScaled,
btcAsset,
btcAsset: displayBtcAsset,
btcAssets: effectiveBtcAssets,
eureAsset,
});
const fundedPortfolioAtFlowTime = baselinePortfolioAtBaselinePrice
@ -119,7 +124,7 @@ export function computePortfolioMetric({
net_btc_units: externalFlowSummary.netBtcUnits.toString(),
net_btc: formatScaledDecimal(unitsToScaledDecimal(
externalFlowSummary.netBtcUnits.toString(),
btcAsset.decimals,
displayBtcAsset.decimals,
)),
net_eure_units: externalFlowSummary.netEureUnits.toString(),
net_eure: formatScaledDecimal(unitsToScaledDecimal(
@ -145,7 +150,8 @@ export function computePortfolioMetric({
},
inventory: buildInventoryView({
inventory: baseline.inventory,
btcAsset,
btcAsset: displayBtcAsset,
btcAssets: effectiveBtcAssets,
eureAsset,
}),
};
@ -162,9 +168,10 @@ export function buildPortfolioMetricId({ baselineInventoryId, currentInventoryId
].join(':');
}
function buildInventoryView({ inventory, btcAsset, eureAsset }) {
function buildInventoryView({ inventory, btcAsset, btcAssets = null, eureAsset }) {
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
const spendable = inventory?.spendable || {};
const btcUnits = String(spendable[btcAsset.assetId] || '0');
const btcUnits = sumAssetUnits(inventory, effectiveBtcAssets).toString();
const eureUnits = String(spendable[eureAsset.assetId] || '0');
return {
@ -172,6 +179,15 @@ function buildInventoryView({ inventory, btcAsset, eureAsset }) {
synced_at: inventory?.synced_at || null,
btc_units: btcUnits,
btc: formatAssetUnits(btcUnits, btcAsset.decimals),
btc_assets: effectiveBtcAssets.map((asset) => {
const units = String(spendable[asset.assetId] || '0');
return {
asset_id: asset.assetId,
label: asset.label || asset.symbol,
units,
amount: formatAssetUnits(units, asset.decimals),
};
}),
eure_units: eureUnits,
eure: formatAssetUnits(eureUnits, eureAsset.decimals),
};
@ -181,8 +197,10 @@ function summarizeExternalFlows({
externalFlows,
currentPriceScaled,
btcAsset,
btcAssets = null,
eureAsset,
}) {
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
let flowCount = 0;
let depositCount = 0;
let withdrawalCount = 0;
@ -205,9 +223,10 @@ function summarizeExternalFlows({
latestEffectiveAt = flow.effective_at || null;
}
if (flow.asset_id === btcAsset.assetId) {
const flowBtcAsset = effectiveBtcAssets.find((asset) => asset.assetId === flow.asset_id);
if (flowBtcAsset) {
netBtcUnits += signedUnits;
const btcAmount = unitsToScaledDecimal(signedUnits.toString(), btcAsset.decimals);
const btcAmount = unitsToScaledDecimal(signedUnits.toString(), flowBtcAsset.decimals);
const flowPriceScaled = parseScaledDecimal(
flow.reference_price_eure_per_btc_at_flow_time || '0',
);
@ -233,6 +252,31 @@ function summarizeExternalFlows({
};
}
function normalizeBtcAssets({ btcAsset, btcAssets = null }) {
const assets = btcAssets?.length ? btcAssets : [btcAsset];
const byId = new Map();
for (const asset of assets) {
if (!asset?.assetId) continue;
byId.set(asset.assetId, asset);
}
return [...byId.values()];
}
function sumAssetUnits(inventory, assets) {
const spendable = inventory?.spendable || {};
return assets.reduce(
(total, asset) => total + BigInt(spendable[asset.assetId] || '0'),
0n,
);
}
function sumAssetScaled(inventory, assets) {
const spendable = inventory?.spendable || {};
return assets.reduce((total, asset) => (
total + unitsToScaledDecimal(String(spendable[asset.assetId] || '0'), asset.decimals)
), 0n);
}
function unitsToScaledDecimal(units, decimals) {
return BigInt(units || '0') * 10n ** BigInt(VALUE_SCALE - decimals);
}

View file

@ -5,7 +5,7 @@ const DEFAULTS = {
nearIntentsWsUrl: 'wss://solver-relay-v2.chaindefuser.com/ws',
nearIntentsRpcUrl: 'https://solver-relay-v2.chaindefuser.com/rpc',
nearBridgeRpcUrl: 'https://bridge.chaindefuser.com/rpc',
nearRpcUrl: 'https://rpc.fastnear.com',
nearRpcUrl: 'https://near.lava.build',
nearVerifierContract: 'intents.near',
nearIntentsPairFilter: DEFAULT_NEAR_INTENTS_PAIR_FILTER,
nearIntentsPairFilterReloadMs: 5_000,
@ -45,8 +45,14 @@ const DEFAULTS = {
postgresUrl: 'postgresql://unrip:unrip@127.0.0.1:5432/unrip',
projectName: 'unrip',
projectNamespace: 'unrip',
tradingBtcAssetId: 'nep141:btc.omft.near',
tradingBtcAssetId: 'nep141:nbtc.bridge.near',
tradingBtcTrackedAssetIds: [
'nep141:nbtc.bridge.near',
'nep141:btc.omft.near',
],
tradingBtcSymbol: 'BTC',
tradingBtcLabel: 'BTC / nBTC reserve',
tradingBtcLegacyLabel: 'BTC / legacy OMFT',
tradingBtcDecimals: 8,
tradingBtcChain: 'btc:mainnet',
tradingBtcWithdrawAddress: '',
@ -138,16 +144,36 @@ function parseBoolean(value, fallback) {
return fallback;
}
function buildAsset({ assetId, symbol, decimals, chain, withdrawAddress = '' }) {
function unique(values) {
return [...new Set(values.filter(Boolean))];
}
function buildAsset({
assetId,
symbol,
decimals,
chain,
withdrawAddress = '',
label = symbol,
role = 'tracked',
}) {
return {
assetId,
symbol,
label,
decimals,
chain,
withdrawAddress,
role,
};
}
function buildBtcAssetLabel(assetId, tradingAssetId) {
if (assetId === tradingAssetId) return DEFAULTS.tradingBtcLabel;
if (assetId === 'nep141:btc.omft.near') return DEFAULTS.tradingBtcLegacyLabel;
return `${DEFAULTS.tradingBtcSymbol} / ${assetId.replace(/^nep141:/, '')}`;
}
function defaultControlBaseUrl({ serviceName, port, namespace }) {
if (process.env.KUBERNETES_SERVICE_HOST) {
return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`;
@ -161,19 +187,46 @@ export function loadConfig({ envPath = '.env' } = {}) {
const tradingBtc = buildAsset({
assetId: process.env.TRADING_BTC_ASSET_ID || DEFAULTS.tradingBtcAssetId,
symbol: process.env.TRADING_BTC_SYMBOL || DEFAULTS.tradingBtcSymbol,
label: process.env.TRADING_BTC_LABEL || DEFAULTS.tradingBtcLabel,
decimals: parseNumber(process.env.TRADING_BTC_DECIMALS, DEFAULTS.tradingBtcDecimals),
chain: process.env.TRADING_BTC_CHAIN || DEFAULTS.tradingBtcChain,
withdrawAddress:
process.env.TRADING_BTC_WITHDRAW_ADDRESS || DEFAULTS.tradingBtcWithdrawAddress,
role: 'trading',
});
const configuredTrackedBtcAssetIds = splitCsv(process.env.TRADING_BTC_TRACKED_ASSET_IDS);
const trackedBtcAssetIds = unique([
tradingBtc.assetId,
...(configuredTrackedBtcAssetIds.length
? configuredTrackedBtcAssetIds
: DEFAULTS.tradingBtcTrackedAssetIds),
]);
const tradingBtcAssets = trackedBtcAssetIds.map((assetId) => {
if (assetId === tradingBtc.assetId) return tradingBtc;
return buildAsset({
assetId,
symbol: tradingBtc.symbol,
label: buildBtcAssetLabel(assetId, tradingBtc.assetId),
decimals: tradingBtc.decimals,
chain: tradingBtc.chain,
withdrawAddress: tradingBtc.withdrawAddress,
role: 'legacy',
});
});
const tradingEure = buildAsset({
assetId: process.env.TRADING_EURE_ASSET_ID || DEFAULTS.tradingEureAssetId,
symbol: process.env.TRADING_EURE_SYMBOL || DEFAULTS.tradingEureSymbol,
label: process.env.TRADING_EURE_LABEL || DEFAULTS.tradingEureSymbol,
decimals: parseNumber(process.env.TRADING_EURE_DECIMALS, DEFAULTS.tradingEureDecimals),
chain: process.env.TRADING_EURE_CHAIN || DEFAULTS.tradingEureChain,
withdrawAddress:
process.env.TRADING_EURE_WITHDRAW_ADDRESS || DEFAULTS.tradingEureWithdrawAddress,
role: 'trading',
});
const trackedAssets = [
...tradingBtcAssets,
tradingEure,
];
const projectName = process.env.PROJECT_NAME || DEFAULTS.projectName;
const projectNamespace =
@ -380,13 +433,13 @@ export function loadConfig({ envPath = '.env' } = {}) {
projectName,
projectNamespace,
tradingBtc,
tradingBtcAssets,
tradingEure,
activePair: `${tradingBtc.assetId}->${tradingEure.assetId}`,
activeAssetIds: [tradingBtc.assetId, tradingEure.assetId],
assetRegistry: new Map([
[tradingBtc.assetId, tradingBtc],
[tradingEure.assetId, tradingEure],
]),
trackedAssets,
trackedAssetIds: trackedAssets.map((asset) => asset.assetId),
assetRegistry: new Map(trackedAssets.map((asset) => [asset.assetId, asset])),
marketReferenceRefreshMs: parseNumber(
process.env.MARKET_REFERENCE_REFRESH_MS,
DEFAULTS.marketReferenceRefreshMs,

View file

@ -335,8 +335,10 @@ export async function insertEnvironmentStatusChange(pool, { topic, event, record
export async function loadPortfolioMetricInputs(pool, {
btcAsset = null,
btcAssets = null,
eureAsset = null,
} = {}) {
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
const [currentInventory, currentPrice, commandAggregate, resultAggregate] = await Promise.all([
loadLatestEventPayload(pool, 'intent_inventory_snapshots'),
loadLatestEventPayload(pool, 'market_price_events'),
@ -376,12 +378,13 @@ export async function loadPortfolioMetricInputs(pool, {
const externalFlows = (
baselineInventory
&& baselinePrice
&& btcAsset?.assetId
&& effectiveBtcAssets.length
&& eureAsset?.assetId
)
? await loadExternalAssetFlowsSince(pool, {
since: firstCommandAt,
btcAsset,
btcAsset: effectiveBtcAssets[0],
btcAssets: effectiveBtcAssets,
eureAsset,
})
: [];
@ -1258,8 +1261,10 @@ async function loadNearestPricePayload(pool, anchorAt) {
async function loadExternalAssetFlowsSince(pool, {
since,
btcAsset,
btcAssets = null,
eureAsset,
} = {}) {
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
const [depositRows, withdrawalRows] = await Promise.all([
loadCreditedDepositRowsSince(pool, since),
loadCompletedWithdrawalRowsSince(pool, since),
@ -1272,7 +1277,8 @@ async function loadExternalAssetFlowsSince(pool, {
row,
kind: 'deposit',
sign: 1n,
btcAsset,
btcAsset: effectiveBtcAssets[0],
btcAssets: effectiveBtcAssets,
eureAsset,
}));
}
@ -1282,7 +1288,8 @@ async function loadExternalAssetFlowsSince(pool, {
row,
kind: 'withdrawal',
sign: -1n,
btcAsset,
btcAsset: effectiveBtcAssets[0],
btcAssets: effectiveBtcAssets,
eureAsset,
}));
}
@ -1353,6 +1360,7 @@ async function normalizeExternalFlowRow(pool, {
kind,
sign,
btcAsset,
btcAssets = null,
eureAsset,
} = {}) {
const payload = row?.payload || {};
@ -1364,8 +1372,10 @@ async function normalizeExternalFlowRow(pool, {
const effectiveAt = toIsoTimestamp(row.observed_at || row.ingested_at);
const signedUnits = (sign * BigInt(amount)).toString();
let referencePriceAtFlowTime = null;
const effectiveBtcAssets = normalizeBtcAssets({ btcAsset, btcAssets });
const flowBtcAsset = effectiveBtcAssets.find((asset) => asset.assetId === assetId);
if (assetId === btcAsset?.assetId) {
if (flowBtcAsset) {
const nearestPrice = await loadNearestPricePayload(pool, effectiveAt);
referencePriceAtFlowTime = nearestPrice?.payload?.eure_per_btc || null;
} else if (assetId !== eureAsset?.assetId) {
@ -1387,6 +1397,15 @@ async function normalizeExternalFlowRow(pool, {
};
}
function normalizeBtcAssets({ btcAsset = null, btcAssets = null } = {}) {
const assets = btcAssets?.length ? btcAssets : [btcAsset];
return [...new Map(
assets
.filter((asset) => asset?.assetId)
.map((asset) => [asset.assetId, asset]),
).values()];
}
function normalizePortfolioMetricRow(row) {
return {
metric_id: row.metric_id,

View file

@ -38,7 +38,7 @@ function BalancesTable({ items }) {
{items.map((item) => (
<tr key={item.asset_id}>
<td>
<strong>{item.symbol}</strong>
<strong>{item.label || item.symbol}</strong>
<div className="muted mono">{item.asset_id}</div>
</td>
<td className="mono">{item.spendable}</td>
@ -61,11 +61,14 @@ function HandlesList({ handles }) {
{handles.map((handle) => (
<div className="service-card" key={`${handle.chain}:${handle.address || handle.asset_id}`}>
<div className="service-head">
<strong>{handle.symbol}</strong>
<strong>{handle.label || handle.symbol}</strong>
<Pill label={handle.chain} stateLabel="healthy" />
</div>
<div className="service-detail">
<div className="mono">{handle.address || 'Unavailable'}</div>
{handle.asset_ids?.length ? (
<div className="mono">{handle.asset_ids.join(', ')}</div>
) : null}
{handle.memo ? <div className="mono">{`Memo ${handle.memo}`}</div> : null}
<div>{`Refreshed ${formatTimestamp(handle.refreshed_at)}`}</div>
</div>
@ -209,7 +212,7 @@ function WithdrawalEstimateForm({ balances, withdrawalDefaults, onControl }) {
>
{(balances || []).map((item) => (
<option key={item.asset_id} value={item.asset_id}>
{item.symbol}
{item.label || item.symbol}
</option>
))}
</select>

View file

@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
bridgeDepositAssetId,
intentsAssetIdFromNearTokenId,
} from '../src/core/bridge-assets.mjs';
const NBTC = 'nep141:nbtc.bridge.near';
const LEGACY_BTC = 'nep141:btc.omft.near';
const assetRegistry = new Map([
[NBTC, { assetId: NBTC }],
[LEGACY_BTC, { assetId: LEGACY_BTC }],
]);
test('intentsAssetIdFromNearTokenId normalizes NEAR token ids to verifier asset ids', () => {
assert.equal(intentsAssetIdFromNearTokenId('nbtc.bridge.near'), NBTC);
assert.equal(intentsAssetIdFromNearTokenId(NBTC), NBTC);
assert.equal(intentsAssetIdFromNearTokenId(''), null);
});
test('bridgeDepositAssetId uses credited bridge near_token_id instead of chain-only fallback', () => {
assert.equal(bridgeDepositAssetId({
near_token_id: 'btc.omft.near',
defuse_asset_identifier: 'btc:mainnet:native',
}, {
assetRegistry,
fallbackAssetId: NBTC,
}), LEGACY_BTC);
assert.equal(bridgeDepositAssetId({
near_token_id: 'nbtc.bridge.near',
defuse_asset_identifier: 'btc:mainnet:native',
}, {
assetRegistry,
fallbackAssetId: LEGACY_BTC,
}), NBTC);
});

View file

@ -0,0 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../src/lib/config.mjs';
const ENV_KEYS = [
'NEAR_RPC_URL',
'NEAR_INTENTS_PAIR_FILTER',
'TRADING_BTC_ASSET_ID',
'TRADING_BTC_TRACKED_ASSET_IDS',
'TRADING_BTC_LABEL',
'TRADING_EURE_ASSET_ID',
];
const NBTC = 'nep141:nbtc.bridge.near';
const LEGACY_BTC = 'nep141:btc.omft.near';
const EURE = 'nep141:gnosis-0x420ca0f9b9b604ce0fd9c18ef134c705e5fa3430.omft.near';
function withCleanEnv(fn) {
const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
for (const key of ENV_KEYS) delete process.env[key];
try {
return fn();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value == null) delete process.env[key];
else process.env[key] = value;
}
}
}
test('default config trades nBTC while still tracking legacy BTC', () => withCleanEnv(() => {
const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' });
assert.equal(config.nearRpcUrl, 'https://near.lava.build');
assert.equal(
config.nearIntentsPairFilter,
`${NBTC}->${EURE}`,
);
assert.equal(config.tradingBtc.assetId, NBTC);
assert.deepEqual(config.activeAssetIds, [NBTC, EURE]);
assert.deepEqual(config.trackedAssetIds, [NBTC, LEGACY_BTC, EURE]);
assert.equal(config.assetRegistry.get(NBTC).label, 'BTC / nBTC reserve');
assert.equal(config.assetRegistry.get(LEGACY_BTC).label, 'BTC / legacy OMFT');
assert.equal(config.assetRegistry.get(LEGACY_BTC).role, 'legacy');
}));
test('tracked BTC ids always include the configured trading BTC reserve', () => withCleanEnv(() => {
process.env.TRADING_BTC_ASSET_ID = NBTC;
process.env.TRADING_BTC_TRACKED_ASSET_IDS = LEGACY_BTC;
const config = loadConfig({ envPath: '/tmp/unrip-no-such-env-file' });
assert.deepEqual(config.tradingBtcAssets.map((asset) => asset.assetId), [NBTC, LEGACY_BTC]);
assert.deepEqual(config.activeAssetIds, [NBTC, EURE]);
}));

View file

@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import {
buildFundingVisibility,
correlateFundingObservation,
hasFundingObservationChanged,
} from '../src/core/funding-observations.mjs';
import { buildInventorySnapshot } from '../src/core/inventory.mjs';
import { routeHistoryRecord } from '../src/core/history-records.mjs';
@ -79,6 +80,26 @@ test('funding observation correlates to later credit without losing tx hash', ()
assert.equal(credited.credited_at, '2026-04-03T08:10:00.000Z');
});
test('funding observation republishes when bridge metadata changes credited BTC wrapper', () => {
const previous = correlateFundingObservation({
accountId: 'solver.near',
assetId: 'nep141:nbtc.bridge.near',
chain: 'btc:mainnet',
fundingHandle: 'bc1qexample',
source: 'btc_mempool_space',
txHash: 'btc-tx-1',
amount: '1500',
confirmations: 1,
observedAt: '2026-05-07T13:00:00.000Z',
});
const next = {
...previous,
asset_id: 'nep141:btc.omft.near',
};
assert.equal(hasFundingObservationChanged(previous, next), true);
});
test('history writer routes funding observations into the funding table family', () => {
const routed = routeHistoryRecord({
topic: 'ops.funding_observation',

View file

@ -5,6 +5,11 @@ import { buildBridgeWithdrawalPlan } from '../src/core/liquidity-withdrawals.mjs
const config = {
assetRegistry: new Map([
['nep141:nbtc.bridge.near', {
assetId: 'nep141:nbtc.bridge.near',
chain: 'btc:mainnet',
withdrawAddress: '',
}],
['nep141:btc.omft.near', {
assetId: 'nep141:btc.omft.near',
chain: 'btc:mainnet',
@ -48,6 +53,33 @@ test('buildBridgeWithdrawalPlan creates an external-chain EURe withdrawal plan',
});
});
test('buildBridgeWithdrawalPlan distinguishes nBTC and legacy BTC bridge tokens', () => {
const plan = buildBridgeWithdrawalPlan({
assetId: 'nep141:nbtc.bridge.near',
amount: '10000',
destinationAddress: 'bc1qexample',
supportedTokens: {
legacyBtc: {
near_token_id: 'btc.omft.near',
defuse_asset_identifier: 'btc:mainnet:native',
min_withdrawal_amount: '700',
withdrawal_fee: '1500',
},
nbtc: {
near_token_id: 'nbtc.bridge.near',
defuse_asset_identifier: 'btc:mainnet:native',
min_withdrawal_amount: '700',
withdrawal_fee: '1500',
},
},
config,
});
assert.equal(plan.asset_id, 'nep141:nbtc.bridge.near');
assert.equal(plan.near_token_id, 'nbtc.bridge.near');
assert.equal(plan.defuse_asset_identifier, 'btc:mainnet:native');
});
test('buildBridgeWithdrawalPlan rejects amounts below the bridge minimum', () => {
assert.throws(() => buildBridgeWithdrawalPlan({
assetId: 'nep141:btc.omft.near',

View file

@ -54,6 +54,43 @@ function buildConfig() {
};
}
function buildDualBtcConfig() {
const tradingBtc = {
assetId: 'nep141:nbtc.bridge.near',
symbol: 'BTC',
label: 'BTC / nBTC reserve',
decimals: 8,
chain: 'btc:mainnet',
};
const legacyBtc = {
assetId: 'nep141:btc.omft.near',
symbol: 'BTC',
label: 'BTC / legacy OMFT',
decimals: 8,
chain: 'btc:mainnet',
};
const tradingEure = {
assetId: 'nep141:eure.omft.near',
symbol: 'EURe',
decimals: 18,
chain: 'eth:100',
};
return {
...buildConfig(),
activePair: `${tradingBtc.assetId}->${tradingEure.assetId}`,
tradingBtc,
tradingBtcAssets: [tradingBtc, legacyBtc],
tradingEure,
trackedAssets: [tradingBtc, legacyBtc, tradingEure],
assetRegistry: new Map([
[tradingBtc.assetId, tradingBtc],
[legacyBtc.assetId, legacyBtc],
[tradingEure.assetId, tradingEure],
]),
};
}
test('profitability summary separates baseline, hold, market move, and portfolio comparison', () => {
const summary = buildProfitabilitySummary({
metric: {
@ -752,6 +789,64 @@ test('bootstrap aggregation keeps Funds as default and carries live control stat
assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].reason_code, 'strategy_disarmed');
});
test('bootstrap balances and funding handles distinguish nBTC reserve from legacy BTC', () => {
const config = buildDualBtcConfig();
const bootstrap = buildDashboardBootstrap({
config,
inventorySnapshot: {
ingested_at: '2026-05-07T13:45:00.000Z',
payload: {
synced_at: '2026-05-07T13:45:00.000Z',
reconciliation_status: 'ok',
spendable: {
[config.tradingBtc.assetId]: '100000',
'nep141:btc.omft.near': '201051',
[config.tradingEure.assetId]: '0',
},
pending_inbound: {},
pending_outbound: {},
},
},
marketPrice: {
payload: {
observed_at: '2026-05-07T13:45:00.000Z',
eure_per_btc: '50000',
},
},
recentQuotes: [],
submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [] },
submissionSummary: { total: 0 },
fundingObservations: [],
recentTradeDecisions: [],
recentExecuteTradeCommands: [],
recentExecutionResults: [],
recentAlertTransitions: [],
serviceSnapshots: [{
service: 'liquidity-manager',
state: {
withdrawals_frozen: true,
deposit_addresses: {
'btc:mainnet': {
address: 'bc1qdeposit',
refreshed_at: '2026-05-07T13:44:00.000Z',
},
},
},
}],
});
assert.deepEqual(bootstrap.funds.balances.items.map((item) => item.label), [
'BTC / nBTC reserve',
'BTC / legacy OMFT',
'EURe',
]);
assert.deepEqual(bootstrap.funds.funding.handles[0].asset_ids, [
'nep141:nbtc.bridge.near',
'nep141:btc.omft.near',
]);
assert.equal(bootstrap.funds.funding.handles[0].label, 'btc:mainnet funding handle');
});
test('bootstrap normalizes actionable decision vocabulary before exposing it to the dashboard', () => {
const config = buildConfig();
const bootstrap = buildDashboardBootstrap({

View file

@ -9,6 +9,13 @@ const btcAsset = {
decimals: 8,
};
const nbtcAsset = {
assetId: 'nep141:nbtc.bridge.near',
symbol: 'BTC',
label: 'BTC / nBTC reserve',
decimals: 8,
};
const eureAsset = {
assetId: 'nep141:eure.omft.near',
symbol: 'EURe',
@ -97,6 +104,37 @@ test('portfolio metrics stay available before the first live execution', () => {
assert.equal(metric.price_move_pnl_eure, null);
});
test('portfolio metrics value both tracked BTC wrappers as BTC-equivalent inventory', () => {
const metric = computePortfolioMetric({
baseline: null,
currentInventory: {
inventory_id: 'current-dual-btc',
synced_at: '2026-05-07T13:45:00.000Z',
spendable: {
'nep141:nbtc.bridge.near': '100000',
'nep141:btc.omft.near': '201051',
'nep141:eure.omft.near': '0',
},
},
currentPrice: {
price_id: 'price-dual-btc',
observed_at: '2026-05-07T13:45:00.000Z',
eure_per_btc: '50000',
},
btcAsset: nbtcAsset,
btcAssets: [nbtcAsset, btcAsset],
eureAsset,
});
assert.equal(metric.current_inventory.btc_units, '301051');
assert.equal(metric.current_inventory.btc, '0.00301051');
assert.equal(metric.current_portfolio_value_eure, '150.5255');
assert.deepEqual(metric.current_inventory.btc_assets.map((asset) => asset.asset_id), [
'nep141:nbtc.bridge.near',
'nep141:btc.omft.near',
]);
});
test('portfolio metrics treat later deposits and withdrawals as external cash flows instead of PnL', () => {
const metric = computePortfolioMetric({
baseline: {