diff --git a/projects/keepkey-vault/__tests__/portfolio-extra-contracts.test.ts b/projects/keepkey-vault/__tests__/portfolio-extra-contracts.test.ts new file mode 100644 index 00000000..889b2239 --- /dev/null +++ b/projects/keepkey-vault/__tests__/portfolio-extra-contracts.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { prioritizeExtraContracts, type PortfolioExtraContract } from '../src/bun/portfolio-extra-contracts' + +const contract = (index: number): PortfolioExtraContract => ({ + networkId: 'eip155:1', + contractAddress: `0x${index.toString(16).padStart(40, '0')}`, + decimals: 18, + symbol: `T${index}`, +}) + +describe('prioritizeExtraContracts', () => { + test('keeps a swap destination ahead of Pioneers 20-contract cap', () => { + const custom = Array.from({ length: 25 }, (_, index) => contract(index + 1)) + const destination = { ...custom[24], symbol: 'DEST' } + const merged = prioritizeExtraContracts([destination], custom) + + expect(merged).toHaveLength(25) + expect(merged[0]).toEqual(destination) + expect(merged.slice(0, 20)).toContainEqual(destination) + }) + + test('deduplicates network and address case-insensitively', () => { + const destination = contract(1) + const duplicate = { + ...destination, + networkId: destination.networkId.toUpperCase(), + contractAddress: destination.contractAddress.toUpperCase(), + } + expect(prioritizeExtraContracts([destination], [duplicate])).toEqual([destination]) + }) +}) diff --git a/projects/keepkey-vault/__tests__/swap-tracker-guards.test.ts b/projects/keepkey-vault/__tests__/swap-tracker-guards.test.ts new file mode 100644 index 00000000..2c1ba741 --- /dev/null +++ b/projects/keepkey-vault/__tests__/swap-tracker-guards.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test' +import { + isPlaceholderOutboundTxHash, + selectOutboundTxid, + shouldRetryCompletedSwapMetadata, +} from '../src/shared/swap-tracker-guards' + +describe('outbound transaction guards', () => { + test.each(['', ' ', '0x', '0X', '0', '0000', '0x0000', '0X0000', ' 0x0000 '])( + 'recognizes placeholder %p', + (value) => expect(isPlaceholderOutboundTxHash(value)).toBe(true), + ) + + test('does not mistake a real transaction id for a placeholder', () => { + expect(isPlaceholderOutboundTxHash('0xa806bffa')).toBe(false) + expect(isPlaceholderOutboundTxHash('11111111111111111111111111111111')).toBe(false) + }) + + test('a real integration candidate wins over an earlier placeholder', () => { + expect(selectOutboundTxid(['0x0000', undefined, ' 0xa806bffa '])).toEqual({ + outboundTxid: '0xa806bffa', + placeholderOnly: false, + }) + }) + + test('reports placeholderOnly when no real candidate exists', () => { + expect(selectOutboundTxid([undefined, '', '0X0000'])).toEqual({ placeholderOnly: true }) + }) +}) + +describe('completed metadata retry guard', () => { + test('retries only placeholder-completed swaps and stays bounded', () => { + expect(shouldRetryCompletedSwapMetadata('completed', true, 0)).toBe(true) + expect(shouldRetryCompletedSwapMetadata('completed', true, 5)).toBe(true) + expect(shouldRetryCompletedSwapMetadata('completed', true, 6)).toBe(false) + expect(shouldRetryCompletedSwapMetadata('completed', false, 0)).toBe(false) + expect(shouldRetryCompletedSwapMetadata('confirming', true, 0)).toBe(false) + }) +}) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 5bf65382..925ada6c 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -138,6 +138,8 @@ import { generateReport, reportToPdfBuffer, reportToCsv } from "./reports" import { startAudit, startBtcScan, getAudit, getAuditBtcRaw, getAuditEntry, dismissAudit, markAuditsStale, type AuditDeps } from "./audit-engine" import { chainSupportsDeepScan, chainSupportsLevelScan, chainLevelPath, deriveAddressParams, extractAddress, parseNativeScanResult, parseEvmScanResult, utxoAccountScriptPaths, explorerAddressUrl, pathToBip32, parseBip32Path } from "./chain-scan" import { extractTransactionsFromReport, toCoinTrackerCsv, toZenLedgerCsv } from "./tax-export" +import { assetData as discoveryAssetData } from "@pioneer-platform/pioneer-discovery" +import { prioritizeExtraContracts, type PortfolioExtraContract } from "./portfolio-extra-contracts" import * as os from "os" import * as path from "path" import { EVM_RPC_URLS, getTokenMetadata, broadcastEvmTx } from "./evm-rpc" @@ -2882,7 +2884,7 @@ const rpc = BrowserView.defineRPC({ }, // ── Pioneer integration (batch portfolio API) ──────────────── - getBalances: async ({ forceRefresh = false } = {}) => { + getBalances: async ({ forceRefresh = false, swapDestCaips = [] }: { forceRefresh?: boolean; swapDestCaips?: string[] } = {}) => { if (!engine.wallet) throw new Error('No device connected') // Initialize Pioneer client — isolate failure so device derivation still works @@ -3105,7 +3107,7 @@ const rpc = BrowserView.defineRPC({ const results: ChainBalance[] = [] try { if (!pioneer) throw (pioneerInitError || new Error('Pioneer client not available')) - const extraContracts = getCustomTokens().map(ct => ({ + const customContracts: PortfolioExtraContract[] = getCustomTokens().map(ct => ({ networkId: ct.networkId, contractAddress: ct.contractAddress, decimals: ct.decimals, @@ -3113,6 +3115,58 @@ const rpc = BrowserView.defineRPC({ name: ct.name, icon: ct.iconUrl, })) + const swapDestinationContracts: PortfolioExtraContract[] = [] + // Post-swap reconcile: for a just-received EVM token (swap output), + // force Pioneer to do a direct on-chain balanceOf via extraContracts — + // the indexed (Zapper) source lags a few blocks after a swap and can + // return a stale pre-swap balance marked fresh, which would persist. + const discoveryLookup = discoveryAssetData as unknown as Record + for (const destCaip of swapDestCaips) { + const m = /^(eip155:\d+)\/erc20:(0x[0-9a-fA-F]{40})$/.exec(destCaip) + if (!m) continue + const [, destNetworkId, destContract] = m + const matchingCustom = customContracts.find(c => + c.networkId.toLowerCase() === destNetworkId.toLowerCase() && + c.contractAddress.toLowerCase() === destContract.toLowerCase() + ) + const entry = discoveryLookup[destCaip] || discoveryLookup[destCaip.toLowerCase()] + let decimals: number | undefined = typeof entry?.decimals === 'number' ? entry.decimals : matchingCustom?.decimals + let symbol = entry?.symbol || matchingCustom?.symbol + let name = entry?.name || matchingCustom?.name + if (decimals === undefined) { + // Discovery gap — fall back to a previously cached row for this token + // (even a stale balance carries the correct on-chain decimals). + try { + const cachedForDecimals = getCachedBalances(engine.getDeviceState().deviceId || 'unknown') + for (const cb of cachedForDecimals?.balances || []) { + const tok = cb.tokens?.find(t => t.caip?.toLowerCase() === destCaip.toLowerCase()) + if (typeof tok?.decimals === 'number') { + decimals = tok.decimals + symbol = symbol || tok.symbol + name = name || tok.name + break + } + } + } catch { /* fall through */ } + } + if (decimals === undefined) { + console.warn(`[getBalances] Post-swap reconcile: unknown decimals for ${destCaip} — skipping extraContracts entry`) + continue + } + console.log(`[getBalances] Post-swap reconcile: forcing on-chain balanceOf for ${symbol || destContract} (${destCaip})`) + swapDestinationContracts.push({ + networkId: destNetworkId, + contractAddress: destContract, + decimals, + symbol, + name, + icon: entry?.icon || matchingCustom?.icon, + }) + } + // Pioneer processes at most 20 extra contracts. Swap outputs must + // stay ahead of the user's custom-token list or the reconciliation + // request can be silently truncated. + const extraContracts = prioritizeExtraContracts(swapDestinationContracts, customContracts) // Self-host: when a BTC node is active, BTC balances come from the // node (below), NOT Pioneer — so exclude BTC xpubs from the Pioneer // chunk. Non-BTC chains + price still use Pioneer. kind==='pioneer' diff --git a/projects/keepkey-vault/src/bun/portfolio-extra-contracts.ts b/projects/keepkey-vault/src/bun/portfolio-extra-contracts.ts new file mode 100644 index 00000000..22db0c1f --- /dev/null +++ b/projects/keepkey-vault/src/bun/portfolio-extra-contracts.ts @@ -0,0 +1,28 @@ +export interface PortfolioExtraContract { + networkId: string + contractAddress: string + decimals: number + symbol?: string + name?: string + icon?: string +} + +const contractKey = (entry: PortfolioExtraContract): string => + `${entry.networkId.toLowerCase()}:${entry.contractAddress.toLowerCase()}` + +/** Pioneer caps extraContracts at 20. Put post-swap reconciliation targets + * first, then append custom tokens without duplicating a target contract. */ +export function prioritizeExtraContracts( + swapDestinations: PortfolioExtraContract[], + customContracts: PortfolioExtraContract[], +): PortfolioExtraContract[] { + const result: PortfolioExtraContract[] = [] + const seen = new Set() + for (const entry of [...swapDestinations, ...customContracts]) { + const key = contractKey(entry) + if (seen.has(key)) continue + seen.add(key) + result.push(entry) + } + return result +} diff --git a/projects/keepkey-vault/src/bun/swap-tracker.ts b/projects/keepkey-vault/src/bun/swap-tracker.ts index 6c53230c..72ff5c92 100644 --- a/projects/keepkey-vault/src/bun/swap-tracker.ts +++ b/projects/keepkey-vault/src/bun/swap-tracker.ts @@ -30,6 +30,7 @@ import { assetData as discoveryAssetData } from '@pioneer-platform/pioneer-disco import { VAULT_CHAIN_TO_THOR } from '../shared/swap-discovery' import { extractRelayRequestId } from '../shared/relay-utils' import { classifySwapOutcome, type MidgardActionsResponse } from './swap/classify' +import { selectOutboundTxid } from '../shared/swap-tracker-guards' /** Resolve display data from a CAIP-19. CAIP is the only identifier the swap * layer accepts; symbols / asset names / display names are derived here for @@ -540,9 +541,27 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { const confirmations = ignoreNonFinalPioneer ? swap.confirmations : (remoteSwap.confirmations ?? swap.confirmations) const outboundConfirmations = remoteSwap.outboundConfirmations const outboundRequiredConfirmations = remoteSwap.outboundRequiredConfirmations - const outboundTxid = remoteSwap.thorchainData?.outboundTxHash - || remoteSwap.mayachainData?.outboundTxHash - || remoteSwap.relayData?.outTxHashes?.[0] + const { outboundTxid, placeholderOnly: outboundHashIsPlaceholder } = selectOutboundTxid([ + remoteSwap.thorchainData?.outboundTxHash, + remoteSwap.mayachainData?.outboundTxHash, + remoteSwap.relayData?.outTxHashes?.[0], + ]) + // Malformed provider rows have been seen carrying an all-zero outbound hash + // (paired with a bogus received amount) — treat the hash as not-yet-known + // and don't trust the received amount from the same response. + if (outboundHashIsPlaceholder) { + console.warn(`${TAG} Ignoring placeholder all-zero outbound hash for ${swap.txid.slice(0, 10)}... — treating outbound as not yet known`) + } + // Once a provider has admitted its terminal metadata is a placeholder, keep + // that state until a real outbound id arrives. This survives subsequent rows + // that omit the field entirely and lets the UI perform bounded repair polls. + const nextTrackingMetadataPending = newStatus === 'failed' || newStatus === 'refunded' + ? false + : outboundTxid + ? false + : outboundHashIsPlaceholder + ? true + : !!swap.trackingMetadataPending // Pioneer surfaces the detected underlying protocol in `details.protocol.protocol`. // If the quote-time parse missed `swapper` (common for aggregator routes where // ShapeShift's response shape varies), this is the authoritative value. @@ -564,6 +583,11 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { const errorMsg = remoteSwap.error?.userMessage || remoteSwap.error?.message || (remoteSwap.error ? String(remoteSwap.error) : undefined) const timeEstimate = remoteSwap.timeEstimate + // Reject amounts <= 0 / NaN, and reject any amount from a provider row whose + // outbound metadata is still known to be a placeholder. + const receivedOutput: string | undefined = (!nextTrackingMetadataPending && remoteSwap.buyAsset?.amount && parseFloat(remoteSwap.buyAsset.amount) > 0) + ? remoteSwap.buyAsset.amount + : undefined // ── Inbound (input tx) on-chain location + timing ── // Pioneer records where the INPUT tx landed under `blockchainTxData`, the @@ -622,6 +646,8 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { confirmations !== swap.confirmations || (outboundConfirmations !== undefined && outboundConfirmations !== swap.outboundConfirmations) || (acceptOutboundTxid && outboundTxid && outboundTxid !== swap.outboundTxid) || + nextTrackingMetadataPending !== !!swap.trackingMetadataPending || + (receivedOutput !== undefined && receivedOutput !== swap.receivedOutput) || (detectedSwapper && detectedSwapper !== swap.swapper) || shouldClearSwapper || inboundDataChanged @@ -633,6 +659,7 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { if (outboundConfirmations !== undefined) swap.outboundConfirmations = outboundConfirmations if (outboundRequiredConfirmations !== undefined) swap.outboundRequiredConfirmations = outboundRequiredConfirmations if (acceptOutboundTxid && outboundTxid) swap.outboundTxid = outboundTxid + swap.trackingMetadataPending = nextTrackingMetadataPending if (shouldClearSwapper) swap.swapper = undefined if (errorMsg) swap.error = errorMsg if (detectedSwapper && !swap.swapper) swap.swapper = detectedSwapper @@ -652,9 +679,6 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { swap.estimatedTime = timeEstimate.total_swap_seconds } - const receivedOutput: string | undefined = (remoteSwap.buyAsset?.amount && parseFloat(remoteSwap.buyAsset.amount) > 0) - ? remoteSwap.buyAsset.amount - : undefined // No CACAO rescale here — Pioneer now reports CACAO at its native 10 decimals. // (Mirrors the parseQuoteResponse fix; see that comment for history of PR #192's // obsolete /100 correction.) @@ -695,7 +719,7 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { pushUpdate(swap) if (isFinal) { - if (newStatus === 'completed' && swap.deviceId) { + if (newStatus === 'completed' && !swap.trackingMetadataPending && swap.deviceId) { try { recordSwap({ deviceId: swap.deviceId, @@ -755,6 +779,10 @@ function readSwapFromDb(txid: string, deviceId?: string, walletId?: string): Pen errorActionable: r.errorActionable, errorElapsedMinutes: r.errorElapsedMinutes, midgardClassified: !!(r.outboundChainId || r.refundReason), + trackingMetadataPending: r.status === 'completed' && ( + selectOutboundTxid([r.outboundTxid]).placeholderOnly || + (!r.outboundTxid && !r.receivedOutput) + ), } } diff --git a/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx index dd08bdea..bdab3d44 100644 --- a/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx +++ b/projects/keepkey-vault/src/mainview/components/ActivityTracker.tsx @@ -113,7 +113,7 @@ export function ActivityTracker() { fetchActivities() if (swap.status === 'completed' || swap.status === 'refunded') { window.dispatchEvent(new CustomEvent('keepkey-swap-completed', { - detail: { fromChainId: swap.fromChainId, toChainId: swap.toChainId } + detail: { fromChainId: swap.fromChainId, toChainId: swap.toChainId, toCaip: swap.toCaip } })) } }) diff --git a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx index f4fc5aff..c69c4616 100644 --- a/projects/keepkey-vault/src/mainview/components/Dashboard.tsx +++ b/projects/keepkey-vault/src/mainview/components/Dashboard.tsx @@ -1066,7 +1066,9 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin // Manual refresh: fetch live data from Pioneer API // forceRefresh=true bypasses Pioneer's balance cache — only pass it on explicit user action - const refreshBalances = useCallback(async (forceRefresh = false) => { + // swapDestCaip: CAIP-19 of a just-received swap output token — forwarded so the + // backend asks Pioneer for a direct on-chain balanceOf (indexers lag post-swap). + const refreshBalances = useCallback(async (forceRefresh = false, swapDestCaip?: string) => { // Watch-only: no device, so re-fetch from Pioneer using cached addresses. if (watchOnly) { if (loadingBalancesRef.current) return @@ -1103,7 +1105,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin setLoadingBalances(true) try { - const result = await rpcRequest('getBalances', { forceRefresh }, 200000) + const result = await rpcRequest('getBalances', swapDestCaip ? { forceRefresh, swapDestCaips: [swapDestCaip] } : { forceRefresh }, 200000) if (result && gen === refreshGenRef.current) { const tokenTotal = result.reduce((n, b) => n + (b.tokens?.length || 0), 0) const balTotal = result.reduce((n, b) => n + (b.balanceUsd || 0), 0) @@ -1241,11 +1243,15 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin useEffect(() => { let t1: ReturnType let t2: ReturnType - const handler = () => { + const handler = (e: Event) => { console.log('[Dashboard] Swap completed — force-refreshing balances') - refreshBalances(true) - t1 = setTimeout(() => refreshBalances(true), 30000) - t2 = setTimeout(() => refreshBalances(true), 90000) + // EVM token output: pass its CAIP so Pioneer does a direct on-chain + // balanceOf instead of trusting the (still-lagging) indexer. + const toCaip: string | undefined = (e as CustomEvent).detail?.toCaip + const destTokenCaip = typeof toCaip === 'string' && toCaip.includes('/erc20:') ? toCaip : undefined + refreshBalances(true, destTokenCaip) + t1 = setTimeout(() => refreshBalances(true, destTokenCaip), 30000) + t2 = setTimeout(() => refreshBalances(true, destTokenCaip), 90000) } window.addEventListener('keepkey-swap-completed', handler) return () => { diff --git a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx index e8ed648f..5b123c8e 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapDialog.tsx @@ -29,6 +29,7 @@ import { useEvmAddresses } from "../hooks/useEvmAddresses" import { AssetPickerDialog } from "./AssetPickerDialog" import { networkDisplayName, ellipsizeCaip, parseCaip } from "../../shared/swap-discovery" import { isSymbolSquatter } from "../../shared/symbolSquatter" +import { shouldRetryCompletedSwapMetadata } from "../../shared/swap-tracker-guards" import { KeepKeyDevice, RouteMap, SpinningDevice } from "./v3" import calculatingGif from "../assets/swap/calculating.gif" import shiftingGif from "../assets/swap/shifting.gif" @@ -964,10 +965,12 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, if (!txid || phase !== 'submitted') return let cancelled = false let timer: ReturnType | null = null + let completedMetadataPolls = 0 const tick = async () => { if (cancelled) return + let snap: PendingSwap | null = null try { - const snap = await rpcRequest('refreshSwap', { txid }) + snap = await rpcRequest('refreshSwap', { txid }) // Guard after the await — dialog may have closed or switched txids. if (cancelled) return // Apply fields that the tracker only pushes once (nearTxHash, relayRequestId) @@ -977,7 +980,12 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, applyInboundSnap(snap) } catch { /* swap-update push will retry on next tick */ } if (cancelled) return - const s = liveStatusRef.current + const s = snap?.status || liveStatusRef.current + if (shouldRetryCompletedSwapMetadata(s, snap?.trackingMetadataPending, completedMetadataPolls)) { + completedMetadataPolls += 1 + timer = setTimeout(tick, 10_000) + return + } if (s === 'completed' || s === 'failed' || s === 'refunded') return timer = setTimeout(tick, 10_000) } @@ -1070,7 +1078,12 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap, playCompletionSound() setTimeout(() => setShowConfetti(false), 1500) // Fetch updated balances to show before/after diff - rpcRequest('getBalances', undefined, 120000) + const swapDestCaip = toAsset?.chainFamily === 'evm' && toAsset.contractAddress ? toAsset.caip : undefined + rpcRequest( + 'getBalances', + swapDestCaip ? { forceRefresh: true, swapDestCaips: [swapDestCaip] } : { forceRefresh: true }, + 120000, + ) .then((result) => { if (!result || !fromAsset || !toAsset) return const fromCb = result.find(b => b.chainId === fromAsset.chainId) diff --git a/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx index c222445a..1a33a7d3 100644 --- a/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx +++ b/projects/keepkey-vault/src/mainview/components/SwapTracker.tsx @@ -84,7 +84,7 @@ export function SwapTracker() { // Trigger balance refresh for both chains when swap completes if (swap.status === 'completed' || swap.status === 'refunded') { window.dispatchEvent(new CustomEvent('keepkey-swap-completed', { - detail: { fromChainId: swap.fromChainId, toChainId: swap.toChainId } + detail: { fromChainId: swap.fromChainId, toChainId: swap.toChainId, toCaip: swap.toCaip } })) } }) diff --git a/projects/keepkey-vault/src/shared/rpc-schema.ts b/projects/keepkey-vault/src/shared/rpc-schema.ts index bcbe11ab..c312a037 100644 --- a/projects/keepkey-vault/src/shared/rpc-schema.ts +++ b/projects/keepkey-vault/src/shared/rpc-schema.ts @@ -90,7 +90,7 @@ export type VaultRPCSchema = ElectrobunRPCSchema & { hiveSignTx: { params: any; response: any } // ── Pioneer integration ───────────────────────────────────────── - getBalances: { params: { forceRefresh?: boolean }; response: ChainBalance[] } + getBalances: { params: { forceRefresh?: boolean; swapDestCaips?: string[] }; response: ChainBalance[] } /** forceRefresh defaults to TRUE (user-clicked refresh / tx pushes must bypass * Pioneer's cache). Pass false only when Pioneer's cache is known-fresh. */ getBalance: { params: { chainId: string; forceRefresh?: boolean }; response: ChainBalance } diff --git a/projects/keepkey-vault/src/shared/swap-tracker-guards.ts b/projects/keepkey-vault/src/shared/swap-tracker-guards.ts new file mode 100644 index 00000000..16a75312 --- /dev/null +++ b/projects/keepkey-vault/src/shared/swap-tracker-guards.ts @@ -0,0 +1,41 @@ +export interface OutboundTxSelection { + outboundTxid?: string + /** True only when at least one candidate was a placeholder and no real hash won. */ + placeholderOnly: boolean +} + +/** Providers use empty / zeroed strings while an outbound transaction is not + * known yet. Normalize case and whitespace so every sentinel shape is rejected. */ +export function isPlaceholderOutboundTxHash(value: unknown): boolean { + if (typeof value !== 'string') return false + const normalized = value.trim().toLowerCase() + return normalized === '' || normalized === '0x' || /^(?:0x)?0+$/.test(normalized) +} + +/** Select the first real outbound id without letting an earlier placeholder + * mask a later integration-specific candidate. */ +export function selectOutboundTxid(candidates: unknown[]): OutboundTxSelection { + let sawPlaceholder = false + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue + if (isPlaceholderOutboundTxHash(candidate)) { + sawPlaceholder = true + continue + } + return { outboundTxid: candidate.trim(), placeholderOnly: false } + } + return { placeholderOnly: sawPlaceholder } +} + +export const MAX_COMPLETED_METADATA_POLLS = 6 + +/** Completed swaps normally stop UI polling. Rows explicitly marked as having + * placeholder metadata get a small bounded window to acquire the real values. */ +export function shouldRetryCompletedSwapMetadata( + status: string | undefined, + metadataPending: boolean | undefined, + attempts: number, + maxAttempts = MAX_COMPLETED_METADATA_POLLS, +): boolean { + return status === 'completed' && metadataPending === true && attempts < maxAttempts +} diff --git a/projects/keepkey-vault/src/shared/types.ts b/projects/keepkey-vault/src/shared/types.ts index 5a4a1a5a..0e29b865 100644 --- a/projects/keepkey-vault/src/shared/types.ts +++ b/projects/keepkey-vault/src/shared/types.ts @@ -1155,6 +1155,9 @@ export interface PendingSwap { outboundConfirmations?: number outboundRequiredConfirmations?: number outboundTxid?: string + /** Provider returned a placeholder outbound id with terminal status. The UI + * keeps polling for a bounded window until the real hash/amount arrives. */ + trackingMetadataPending?: boolean createdAt: number // unix ms updatedAt: number // unix ms completedAt?: number // unix ms — when terminal status reached