Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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])
})
})
39 changes: 39 additions & 0 deletions projects/keepkey-vault/__tests__/swap-tracker-guards.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
58 changes: 56 additions & 2 deletions projects/keepkey-vault/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -2882,7 +2884,7 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
},

// ── 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
Expand Down Expand Up @@ -3105,14 +3107,66 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
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,
symbol: ct.symbol,
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<string, { symbol?: string; name?: string; icon?: string; decimals?: number }>
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'
Expand Down
28 changes: 28 additions & 0 deletions projects/keepkey-vault/src/bun/portfolio-extra-contracts.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
for (const entry of [...swapDestinations, ...customContracts]) {
const key = contractKey(entry)
if (seen.has(key)) continue
seen.add(key)
result.push(entry)
}
return result
}
42 changes: 35 additions & 7 deletions projects/keepkey-vault/src/bun/swap-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}))
}
})
Expand Down
18 changes: 12 additions & 6 deletions projects/keepkey-vault/src/mainview/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1103,7 +1105,7 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin
setLoadingBalances(true)

try {
const result = await rpcRequest<ChainBalance[]>('getBalances', { forceRefresh }, 200000)
const result = await rpcRequest<ChainBalance[]>('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)
Expand Down Expand Up @@ -1241,11 +1243,15 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin
useEffect(() => {
let t1: ReturnType<typeof setTimeout>
let t2: ReturnType<typeof setTimeout>
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 () => {
Expand Down
19 changes: 16 additions & 3 deletions projects/keepkey-vault/src/mainview/components/SwapDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -964,10 +965,12 @@ export function SwapDialog({ open, onClose, chain, balance, address, resumeSwap,
if (!txid || phase !== 'submitted') return
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
let completedMetadataPolls = 0
const tick = async () => {
if (cancelled) return
let snap: PendingSwap | null = null
try {
const snap = await rpcRequest<any>('refreshSwap', { txid })
snap = await rpcRequest<PendingSwap | null>('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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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<ChainBalance[]>('getBalances', undefined, 120000)
const swapDestCaip = toAsset?.chainFamily === 'evm' && toAsset.contractAddress ? toAsset.caip : undefined
rpcRequest<ChainBalance[]>(
'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)
Expand Down
Loading