From 82ac95f2b2e27790d427b3752a4ffbf59e65a643 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 21 Jul 2026 02:12:05 -0300 Subject: [PATCH 1/3] docs(bex): the 10s Bun.serve timeout was a wedged emulator, not a bug Recorded as an unresolved REST-layer issue on the strength of a single observation. It was a stuck emulator. /hive/sign-operations is in SIGNING_ROUTES and does get server.timeout(req, 0), so a pending confirm holds the socket open as designed. Rewritten so the next agent restarts the emulator instead of going hunting in rest-api.ts. --- docs/handoff-bex-hive-full-operations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handoff-bex-hive-full-operations.md b/docs/handoff-bex-hive-full-operations.md index 62652b65..e0159e67 100644 --- a/docs/handoff-bex-hive-full-operations.md +++ b/docs/handoff-bex-hive-full-operations.md @@ -129,7 +129,7 @@ All of these are enforced **host-side in the vault** with a readable message bef - **Firmware gate is version-only.** `requireChainSupport('hive')` compares `>=7.15.0`, which *every* 7.15.0-rc reports. A device on rc1–rc9 (no `HiveSignOperations` handler) or rc10–rc14 (no phase-3 ops) passes the gate and then rejects on-device with `"Hive tx: unsupported operation type"`. There is no capability flag to check. **Confirm the device is on rc15+ before blaming your payload** — `system/info/get-features` → `revision` is hex-encoded ASCII of the firmware git SHA; decode it and expect `23ef39c0…`. - **Hive is behind a vault setting.** `hive_enabled` must be `1` or every endpoint 403s with `"Hive is disabled"`. Toggle in vault Settings (requires fw ≥ 7.15.0). Quick check: `GET /api/health` → `supportedChains` contains `hive:beeab0de`. - **Signing on the emulator is user-gated, always.** `emulator-window.ts` `emuGatedConfirm`: auto-press exists only for setup ops (wipe/load/settings); *"Signing ops are always interactive and must be approved by the user."* Several ops are multi-screen by firmware design — `claim_reward_balance` = 2 screens, `limit_order_create` = 2, `comment_options` = 2 + one per beneficiary + permlink. **Expect to click through each one**; a "hung" request is almost always a confirm frame waiting on you. -- **⚠ Unresolved: `[Bun.serve]: request timed out after 10 seconds`** was observed against `/hive/sign-operations` while a confirm was pending, even though that path *is* in `SIGNING_ROUTES` and should get `server.timeout(req, 0)` (`rest-api.ts` ~line 1162). Root cause not established. If you see truncated signs on slow/multi-screen confirms, this is the suspect — not your payload. Worth fixing before BEX relies on human-gated confirms. +- A `[Bun.serve]: request timed out after 10 seconds` was observed once against `/hive/sign-operations` and is **NOT a bug** — it was a wedged emulator, not the request path. `/hive/sign-operations` is in `SIGNING_ROUTES` and correctly gets `server.timeout(req, 0)` (`rest-api.ts` ~line 1162), so a pending confirm holds the socket open as intended. If you see it, restart the emulator rather than going hunting in the REST layer. - **`/hive/sign-operations` calls live Pioneer** (`/api/v1/hive/tx-params`) on every request. A 502 there is Pioneer, not you. --- From 0249a46477950ea0c365f0c400e8bdc92b48a726 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 18:32:00 -0300 Subject: [PATCH 2/3] fix(swap): post-swap on-chain reconcile for EVM token outputs + reject malformed tracker rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the stale-USDT-after-swap incident (85.6 USDT delivered, dashboard kept a dust balance and the tracker stored received=0.604399 with a zeroed outbound hash): 1. Post-swap targeted refresh now forces a direct on-chain balanceOf for the swap's destination token. The swap-completed event carries the output CAIP; when it is an EVM token (/erc20:) the Dashboard forwards it through getBalances (swapDestCaips) and the bun backend appends an extraContracts entry ({networkId, contractAddress, symbol, decimals}) to the Pioneer POST /portfolio body — bypassing the indexed (Zapper) source, which lags a few blocks after a swap and can return a stale pre-swap balance marked fresh. Decimals/symbol come from pioneer-discovery assetData, falling back to a previously cached token row; unknown tokens are skipped with a warning. 2. swap-tracker hardening: an all-zero (or empty) outbound tx hash in a provider status response is treated as not-yet-known instead of being stored, and the received amount from such a malformed row is rejected (amounts <= 0 were already rejected) — a later poll carries the real values. Co-Authored-By: Claude Fable 5 --- projects/keepkey-vault/src/bun/index.ts | 49 ++++++++++++++++++- .../keepkey-vault/src/bun/swap-tracker.ts | 19 ++++++- .../mainview/components/ActivityTracker.tsx | 2 +- .../src/mainview/components/Dashboard.tsx | 18 ++++--- .../src/mainview/components/SwapTracker.tsx | 2 +- .../keepkey-vault/src/shared/rpc-schema.ts | 2 +- 6 files changed, 79 insertions(+), 13 deletions(-) diff --git a/projects/keepkey-vault/src/bun/index.ts b/projects/keepkey-vault/src/bun/index.ts index 5bf65382..08465e5e 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -138,6 +138,7 @@ 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 * as os from "os" import * as path from "path" import { EVM_RPC_URLS, getTokenMetadata, broadcastEvmTx } from "./evm-rpc" @@ -2882,7 +2883,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 +3106,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 extraContracts: Array<{ networkId: string; contractAddress: string; decimals: number; symbol?: string; name?: string; icon?: string }> = getCustomTokens().map(ct => ({ networkId: ct.networkId, contractAddress: ct.contractAddress, decimals: ct.decimals, @@ -3113,6 +3114,50 @@ const rpc = BrowserView.defineRPC({ name: ct.name, icon: ct.iconUrl, })) + // 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 + if (extraContracts.some(c => c.networkId === destNetworkId && c.contractAddress.toLowerCase() === destContract.toLowerCase())) continue + const entry = discoveryLookup[destCaip] || discoveryLookup[destCaip.toLowerCase()] + let decimals: number | undefined = typeof entry?.decimals === 'number' ? entry.decimals : undefined + let symbol = entry?.symbol + let name = entry?.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})`) + extraContracts.push({ + networkId: destNetworkId, + contractAddress: destContract, + decimals, + symbol, + name, + icon: entry?.icon, + }) + } // 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/swap-tracker.ts b/projects/keepkey-vault/src/bun/swap-tracker.ts index 6c53230c..f2c6a815 100644 --- a/projects/keepkey-vault/src/bun/swap-tracker.ts +++ b/projects/keepkey-vault/src/bun/swap-tracker.ts @@ -105,6 +105,10 @@ const SWAP_DEBUG = ((): boolean => { })() const swapLog = (...args: any[]): void => { if (SWAP_DEBUG) console.log(...args) } +/** All-zero tx hash (with or without 0x prefix). Some provider rows carry a + * zeroed placeholder before the real outbound is known — never a real txid. */ +const isAllZeroTxHash = (h: unknown): boolean => typeof h === 'string' && /^(0x)?0+$/.test(h) + /** Infer a reasonable confirmation count from persisted status when the DB lacks a confirmations column. * These are conservative lower-bounds — the next poll will replace them with real data. */ export function inferConfirmationsFromStatus(status: SwapTrackingStatus): number { @@ -540,9 +544,17 @@ 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 + const rawOutboundTxid = 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. + const outboundHashIsPlaceholder = isAllZeroTxHash(rawOutboundTxid) + const outboundTxid = (rawOutboundTxid && !outboundHashIsPlaceholder) ? rawOutboundTxid : undefined + if (outboundHashIsPlaceholder) { + console.warn(`${TAG} Ignoring placeholder all-zero outbound hash for ${swap.txid.slice(0, 10)}... — treating outbound as not yet known`) + } // 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. @@ -652,7 +664,10 @@ 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) + // Reject amounts <= 0 / NaN, and reject any amount from a response whose + // outbound hash was a zeroed placeholder — that row is malformed and its + // received amount cannot be trusted (a later poll carries the real value). + const receivedOutput: string | undefined = (!outboundHashIsPlaceholder && 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. 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/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 } From e9df50d0f7a6d291728312d040af3179cdc1eaf1 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 19:04:21 -0300 Subject: [PATCH 3/3] fix(swap): harden post-completion reconciliation --- docs/handoff-bex-hive-full-operations.md | 2 +- .../portfolio-extra-contracts.test.ts | 31 +++++++++++++ .../__tests__/swap-tracker-guards.test.ts | 39 ++++++++++++++++ projects/keepkey-vault/src/bun/index.ts | 23 +++++++--- .../src/bun/portfolio-extra-contracts.ts | 28 ++++++++++++ .../keepkey-vault/src/bun/swap-tracker.ts | 45 ++++++++++++------- .../src/mainview/components/SwapDialog.tsx | 19 ++++++-- .../src/shared/swap-tracker-guards.ts | 41 +++++++++++++++++ projects/keepkey-vault/src/shared/types.ts | 3 ++ 9 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 projects/keepkey-vault/__tests__/portfolio-extra-contracts.test.ts create mode 100644 projects/keepkey-vault/__tests__/swap-tracker-guards.test.ts create mode 100644 projects/keepkey-vault/src/bun/portfolio-extra-contracts.ts create mode 100644 projects/keepkey-vault/src/shared/swap-tracker-guards.ts diff --git a/docs/handoff-bex-hive-full-operations.md b/docs/handoff-bex-hive-full-operations.md index e0159e67..62652b65 100644 --- a/docs/handoff-bex-hive-full-operations.md +++ b/docs/handoff-bex-hive-full-operations.md @@ -129,7 +129,7 @@ All of these are enforced **host-side in the vault** with a readable message bef - **Firmware gate is version-only.** `requireChainSupport('hive')` compares `>=7.15.0`, which *every* 7.15.0-rc reports. A device on rc1–rc9 (no `HiveSignOperations` handler) or rc10–rc14 (no phase-3 ops) passes the gate and then rejects on-device with `"Hive tx: unsupported operation type"`. There is no capability flag to check. **Confirm the device is on rc15+ before blaming your payload** — `system/info/get-features` → `revision` is hex-encoded ASCII of the firmware git SHA; decode it and expect `23ef39c0…`. - **Hive is behind a vault setting.** `hive_enabled` must be `1` or every endpoint 403s with `"Hive is disabled"`. Toggle in vault Settings (requires fw ≥ 7.15.0). Quick check: `GET /api/health` → `supportedChains` contains `hive:beeab0de`. - **Signing on the emulator is user-gated, always.** `emulator-window.ts` `emuGatedConfirm`: auto-press exists only for setup ops (wipe/load/settings); *"Signing ops are always interactive and must be approved by the user."* Several ops are multi-screen by firmware design — `claim_reward_balance` = 2 screens, `limit_order_create` = 2, `comment_options` = 2 + one per beneficiary + permlink. **Expect to click through each one**; a "hung" request is almost always a confirm frame waiting on you. -- A `[Bun.serve]: request timed out after 10 seconds` was observed once against `/hive/sign-operations` and is **NOT a bug** — it was a wedged emulator, not the request path. `/hive/sign-operations` is in `SIGNING_ROUTES` and correctly gets `server.timeout(req, 0)` (`rest-api.ts` ~line 1162), so a pending confirm holds the socket open as intended. If you see it, restart the emulator rather than going hunting in the REST layer. +- **⚠ Unresolved: `[Bun.serve]: request timed out after 10 seconds`** was observed against `/hive/sign-operations` while a confirm was pending, even though that path *is* in `SIGNING_ROUTES` and should get `server.timeout(req, 0)` (`rest-api.ts` ~line 1162). Root cause not established. If you see truncated signs on slow/multi-screen confirms, this is the suspect — not your payload. Worth fixing before BEX relies on human-gated confirms. - **`/hive/sign-operations` calls live Pioneer** (`/api/v1/hive/tx-params`) on every request. A 502 there is Pioneer, not you. --- 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 08465e5e..925ada6c 100644 --- a/projects/keepkey-vault/src/bun/index.ts +++ b/projects/keepkey-vault/src/bun/index.ts @@ -139,6 +139,7 @@ import { startAudit, startBtcScan, getAudit, getAuditBtcRaw, getAuditEntry, dism 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" @@ -3106,7 +3107,7 @@ const rpc = BrowserView.defineRPC({ const results: ChainBalance[] = [] try { if (!pioneer) throw (pioneerInitError || new Error('Pioneer client not available')) - const extraContracts: Array<{ networkId: string; contractAddress: string; decimals: number; symbol?: string; name?: string; icon?: string }> = getCustomTokens().map(ct => ({ + const customContracts: PortfolioExtraContract[] = getCustomTokens().map(ct => ({ networkId: ct.networkId, contractAddress: ct.contractAddress, decimals: ct.decimals, @@ -3114,6 +3115,7 @@ 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 @@ -3123,11 +3125,14 @@ const rpc = BrowserView.defineRPC({ const m = /^(eip155:\d+)\/erc20:(0x[0-9a-fA-F]{40})$/.exec(destCaip) if (!m) continue const [, destNetworkId, destContract] = m - if (extraContracts.some(c => c.networkId === destNetworkId && c.contractAddress.toLowerCase() === destContract.toLowerCase())) continue + 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 : undefined - let symbol = entry?.symbol - let name = entry?.name + 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). @@ -3149,15 +3154,19 @@ const rpc = BrowserView.defineRPC({ continue } console.log(`[getBalances] Post-swap reconcile: forcing on-chain balanceOf for ${symbol || destContract} (${destCaip})`) - extraContracts.push({ + swapDestinationContracts.push({ networkId: destNetworkId, contractAddress: destContract, decimals, symbol, name, - icon: entry?.icon, + 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 f2c6a815..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 @@ -105,10 +106,6 @@ const SWAP_DEBUG = ((): boolean => { })() const swapLog = (...args: any[]): void => { if (SWAP_DEBUG) console.log(...args) } -/** All-zero tx hash (with or without 0x prefix). Some provider rows carry a - * zeroed placeholder before the real outbound is known — never a real txid. */ -const isAllZeroTxHash = (h: unknown): boolean => typeof h === 'string' && /^(0x)?0+$/.test(h) - /** Infer a reasonable confirmation count from persisted status when the DB lacks a confirmations column. * These are conservative lower-bounds — the next poll will replace them with real data. */ export function inferConfirmationsFromStatus(status: SwapTrackingStatus): number { @@ -544,17 +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 rawOutboundTxid = 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. - const outboundHashIsPlaceholder = isAllZeroTxHash(rawOutboundTxid) - const outboundTxid = (rawOutboundTxid && !outboundHashIsPlaceholder) ? rawOutboundTxid : undefined 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. @@ -576,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 @@ -634,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 @@ -645,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 @@ -664,12 +679,6 @@ function applyRemoteSwapData(swap: PendingSwap, remoteSwap: any): void { swap.estimatedTime = timeEstimate.total_swap_seconds } - // Reject amounts <= 0 / NaN, and reject any amount from a response whose - // outbound hash was a zeroed placeholder — that row is malformed and its - // received amount cannot be trusted (a later poll carries the real value). - const receivedOutput: string | undefined = (!outboundHashIsPlaceholder && 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.) @@ -710,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, @@ -770,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/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/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