From ab078d976a516d6b16365a27c958f6fcd2fe294d Mon Sep 17 00:00:00 2001 From: philipp Date: Thu, 7 May 2026 16:06:26 +0200 Subject: [PATCH] Track nBTC reserve and legacy BTC asset 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. --- deploy/k8s/base/unrip.yaml | 8 +- src/apps/history-writer.mjs | 2 + src/apps/inventory-sync.mjs | 23 ++++- src/apps/liquidity-manager.mjs | 77 ++++++++++----- src/core/bridge-assets.mjs | 43 +++++++++ src/core/funding-observations.mjs | 3 +- src/core/operator-dashboard.mjs | 56 ++++++++--- src/core/pair-filter.mjs | 2 +- src/core/portfolio-metrics.mjs | 70 +++++++++++--- src/lib/config.mjs | 67 +++++++++++-- src/lib/postgres.mjs | 29 +++++- .../static/pages/FundsPage.jsx | 9 +- test/bridge-assets.test.mjs | 39 ++++++++ test/config-assets.test.mjs | 56 +++++++++++ test/funding-observations.test.mjs | 21 ++++ test/liquidity-withdrawals.test.mjs | 32 +++++++ test/operator-dashboard.test.mjs | 95 +++++++++++++++++++ test/portfolio-metrics.test.mjs | 38 ++++++++ 18 files changed, 597 insertions(+), 73 deletions(-) create mode 100644 src/core/bridge-assets.mjs create mode 100644 test/bridge-assets.test.mjs create mode 100644 test/config-assets.test.mjs diff --git a/deploy/k8s/base/unrip.yaml b/deploy/k8s/base/unrip.yaml index b567814..57a4221 100644 --- a/deploy/k8s/base/unrip.yaml +++ b/deploy/k8s/base/unrip.yaml @@ -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: "" diff --git a/src/apps/history-writer.mjs b/src/apps/history-writer.mjs index 6d976aa..f700a67 100644 --- a/src/apps/history-writer.mjs +++ b/src/apps/history-writer.mjs @@ -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, diff --git a/src/apps/inventory-sync.mjs b/src/apps/inventory-sync.mjs index 81a3020..58effd0 100644 --- a/src/apps/inventory-sync.mjs +++ b/src/apps/inventory-sync.mjs @@ -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, }); } } diff --git a/src/apps/liquidity-manager.mjs b/src/apps/liquidity-manager.mjs index c80f4ce..f49c499 100644 --- a/src/apps/liquidity-manager.mjs +++ b/src/apps/liquidity-manager.mjs @@ -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, diff --git a/src/core/bridge-assets.mjs b/src/core/bridge-assets.mjs new file mode 100644 index 0000000..c584502 --- /dev/null +++ b/src/core/bridge-assets.mjs @@ -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; +} diff --git a/src/core/funding-observations.mjs b/src/core/funding-observations.mjs index 684ff2e..1a9acd0 100644 --- a/src/core/funding-observations.mjs +++ b/src/core/funding-observations.mjs @@ -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 diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index dae31cb..fdc54b0 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -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); diff --git a/src/core/pair-filter.mjs b/src/core/pair-filter.mjs index 365e502..9cbde42 100644 --- a/src/core/pair-filter.mjs +++ b/src/core/pair-filter.mjs @@ -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'); diff --git a/src/core/portfolio-metrics.mjs b/src/core/portfolio-metrics.mjs index e2a5dfb..612e1b6 100644 --- a/src/core/portfolio-metrics.mjs +++ b/src/core/portfolio-metrics.mjs @@ -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); } diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 6a82781..c3779b7 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -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, diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 0dd1cfb..386cd1e 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -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, diff --git a/src/operator-dashboard/static/pages/FundsPage.jsx b/src/operator-dashboard/static/pages/FundsPage.jsx index 2771b27..8988a68 100644 --- a/src/operator-dashboard/static/pages/FundsPage.jsx +++ b/src/operator-dashboard/static/pages/FundsPage.jsx @@ -38,7 +38,7 @@ function BalancesTable({ items }) { {items.map((item) => ( - {item.symbol} + {item.label || item.symbol}
{item.asset_id}
{item.spendable} @@ -61,11 +61,14 @@ function HandlesList({ handles }) { {handles.map((handle) => (
- {handle.symbol} + {handle.label || handle.symbol}
{handle.address || 'Unavailable'}
+ {handle.asset_ids?.length ? ( +
{handle.asset_ids.join(', ')}
+ ) : null} {handle.memo ?
{`Memo ${handle.memo}`}
: null}
{`Refreshed ${formatTimestamp(handle.refreshed_at)}`}
@@ -209,7 +212,7 @@ function WithdrawalEstimateForm({ balances, withdrawalDefaults, onControl }) { > {(balances || []).map((item) => ( ))} diff --git a/test/bridge-assets.test.mjs b/test/bridge-assets.test.mjs new file mode 100644 index 0000000..a40a132 --- /dev/null +++ b/test/bridge-assets.test.mjs @@ -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); +}); diff --git a/test/config-assets.test.mjs b/test/config-assets.test.mjs new file mode 100644 index 0000000..739d1e6 --- /dev/null +++ b/test/config-assets.test.mjs @@ -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]); +})); diff --git a/test/funding-observations.test.mjs b/test/funding-observations.test.mjs index 25c9f49..1461fa5 100644 --- a/test/funding-observations.test.mjs +++ b/test/funding-observations.test.mjs @@ -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', diff --git a/test/liquidity-withdrawals.test.mjs b/test/liquidity-withdrawals.test.mjs index 9efefd1..e9c383e 100644 --- a/test/liquidity-withdrawals.test.mjs +++ b/test/liquidity-withdrawals.test.mjs @@ -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', diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index 0c36580..1cd17d7 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -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({ diff --git a/test/portfolio-metrics.test.mjs b/test/portfolio-metrics.test.mjs index ad34003..db4a915 100644 --- a/test/portfolio-metrics.test.mjs +++ b/test/portfolio-metrics.test.mjs @@ -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: {