diff --git a/src/components/domain/tx/verification-status.test.tsx b/src/components/domain/tx/verification-status.test.tsx new file mode 100644 index 00000000..6b69f5a2 --- /dev/null +++ b/src/components/domain/tx/verification-status.test.tsx @@ -0,0 +1,42 @@ +/** + * The distinction under test is the point of the component: passing a comparison and having no + * comparison to pass are different facts, and only the first justifies telling the user that no + * tampering was detected. + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { VerificationStatus } from './verification-status'; + +describe('VerificationStatus', () => { + it('claims no tampering only when a comparison actually happened', () => { + render(); + expect(screen.getByText(/no tampering detected/i)).toBeInTheDocument(); + }); + + it('does not claim verification when there was nothing to compare against', () => { + render(); + + expect(screen.queryByText(/no tampering detected/i)).not.toBeInTheDocument(); + expect(screen.getByText(/no second source/i)).toBeInTheDocument(); + }); + + it('renders nothing when verification was not attempted', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('blocks loudly on failure in strict mode', () => { + render(); + + expect(screen.getByText(/signing blocked/i)).toBeInTheDocument(); + expect(screen.getByText('Quantity differs')).toBeInTheDocument(); + }); + + it('warns rather than blocks when strict mode is off', () => { + render(); + + expect(screen.getByText(/verification warning/i)).toBeInTheDocument(); + expect(screen.queryByText(/signing blocked/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/domain/tx/verification-status.tsx b/src/components/domain/tx/verification-status.tsx index ec9a84dd..f10c9cb5 100644 --- a/src/components/domain/tx/verification-status.tsx +++ b/src/components/domain/tx/verification-status.tsx @@ -7,7 +7,7 @@ */ import type { ReactElement } from 'react'; -import { FiShield, FiShieldOff } from '@/components/icons'; +import { FiShield, FiShieldOff, FiInfo } from '@/components/icons'; /* * The passed state is intentionally low-weight — a small inline badge, not a @@ -18,6 +18,14 @@ import { FiShield, FiShieldOff } from '@/components/icons'; export interface VerificationStatusProps { /** Whether verification passed */ passed?: boolean; + /** + * Whether a second decoding was actually compared against the local one. + * + * Passing with nothing to compare against is not the same as passing a comparison: when the API + * decode is unavailable, the message decoded locally and that is all. Claiming "no tampering + * detected" there asserts most where least was checked, so the two are shown differently. + */ + comparedAgainstApi?: boolean; /** Warning/error message to display */ warning?: string; /** Whether strict mode is enabled (blocks signing on failure) */ @@ -27,12 +35,14 @@ export interface VerificationStatusProps { /** * Displays verification status with appropriate styling. * - * - Green: Verification passed + * - Green: cross-checked and agreed + * - Neutral: decoded locally, but no second source to compare against * - Orange: Verification failed (non-strict mode, warning only) * - Red: Verification failed (strict mode, signing blocked) */ export function VerificationStatus({ passed, + comparedAgainstApi = true, warning, isStrict = true, }: VerificationStatusProps): ReactElement | null { @@ -42,6 +52,16 @@ export function VerificationStatus({ return null; } + // Decoded, but nothing to compare it against. Neither an error nor a clean bill of health. + if (passed === true && !comparedAgainstApi) { + return ( +
+
+ ); + } + // Verification passed — compact inline badge, not a full banner. if (passed === true) { return ( diff --git a/src/pages/requests/psbt/approve.tsx b/src/pages/requests/psbt/approve.tsx index 2b69ff84..5c545563 100644 --- a/src/pages/requests/psbt/approve.tsx +++ b/src/pages/requests/psbt/approve.tsx @@ -135,6 +135,7 @@ export default function ApprovePsbtPage() { const verificationPassed = verification?.passed; + const verificationComparedAgainstApi = verification?.comparedAgainstApi ?? false; const verificationWarning = verification?.warning; const verificationFailed = verificationPassed === false; const isStrictMode = settings?.strictTransactionVerification !== false; @@ -394,6 +395,7 @@ export default function ApprovePsbtPage() { {/* Verification Status (compact badge when passed) */} diff --git a/src/pages/requests/transaction/approve.tsx b/src/pages/requests/transaction/approve.tsx index 04a687b2..e5ff8514 100644 --- a/src/pages/requests/transaction/approve.tsx +++ b/src/pages/requests/transaction/approve.tsx @@ -196,6 +196,7 @@ export default function ApproveTransactionPage() { const feeRateAbsurd = exceedsSaneFeeRate(decodedInfo.fee, decodedInfo.vsize); const hasHighFee = decodedInfo.fee > 10000000 || feeRateAbsurd; // > 0.1 BTC, or an absurd rate const verificationPassed = decodedInfo.verification?.passed; + const verificationComparedAgainstApi = decodedInfo.verification?.comparedAgainstApi ?? false; const verificationWarning = decodedInfo.verification?.warning; const verificationFailed = verificationPassed === false; const isStrictMode = settings?.strictTransactionVerification !== false; @@ -425,6 +426,7 @@ export default function ApproveTransactionPage() { {/* Verification Status (compact badge when passed) */} diff --git a/src/utils/blockchain/counterparty/unpack/__tests__/providerVerify.test.ts b/src/utils/blockchain/counterparty/unpack/__tests__/providerVerify.test.ts index 3371627a..d7cb58d0 100644 --- a/src/utils/blockchain/counterparty/unpack/__tests__/providerVerify.test.ts +++ b/src/utils/blockchain/counterparty/unpack/__tests__/providerVerify.test.ts @@ -80,6 +80,31 @@ describe('verifyProviderTransaction', () => { expect(result.passed).toBe(true); expect(result.mismatches).toEqual([]); expect(result.localUnpack).toBeDefined(); + // ...but nothing was cross-checked, and the caller must be able to tell the difference. + // The API decode is unavailable whenever the endpoint errors or rejects the payload, which + // is exactly when an affirmative "no tampering detected" would be least earned. + expect(result.comparedAgainstApi).toBe(false); + }); + + it('reports comparedAgainstApi=true only when a comparison actually happened', () => { + const payload = bigintHex(XCP_ID) + bigintHex(1000n) + packedAddressHex(TEST_HASH); + const data = buildMessage(MessageTypeId.ENHANCED_SEND, payload); + const apiMessage: ApiCounterpartyMessage = { + messageType: 'enhanced_send', + messageTypeId: MessageTypeId.ENHANCED_SEND, + messageData: { asset: 'XCP', quantity: 1000 }, + description: 'send', + }; + + const result = verifyProviderTransaction(data, apiMessage); + expect(result.passed).toBe(true); + expect(result.comparedAgainstApi).toBe(true); + }); + + it('reports comparedAgainstApi=false when the payload is not Counterparty data', () => { + const result = verifyProviderTransaction('deadbeef'); + expect(result.passed).toBeUndefined(); + expect(result.comparedAgainstApi).toBe(false); }); }); diff --git a/src/utils/blockchain/counterparty/unpack/providerVerify.ts b/src/utils/blockchain/counterparty/unpack/providerVerify.ts index 13fbc094..55993567 100644 --- a/src/utils/blockchain/counterparty/unpack/providerVerify.ts +++ b/src/utils/blockchain/counterparty/unpack/providerVerify.ts @@ -41,6 +41,15 @@ export interface ApiCounterpartyMessage { export interface ProviderVerificationResult { /** Whether verification passed (no critical mismatches). undefined = not attempted. */ passed: boolean | undefined; + /** + * Whether a second decoding was actually compared against the local one. + * + * `passed: true` alone does not mean anything was cross-checked: when the API decode is + * unavailable — a network error, a non-200, or a payload core rejects — there is nothing to + * compare and a successful local unpack is all that happened. Reporting that as verified + * overstates it precisely when the least checking occurred, so the display distinguishes the two. + */ + comparedAgainstApi: boolean; /** Warning message if verification failed or had issues */ warning?: string; /** Detailed list of mismatches found */ @@ -590,6 +599,7 @@ export function verifyProviderTransaction( if (!opReturnData || !isCounterpartyData(opReturnData)) { return { passed: undefined, + comparedAgainstApi: false, mismatches: [], warning: undefined, }; @@ -602,16 +612,19 @@ export function verifyProviderTransaction( if (!localUnpack.success || !localUnpack.data) { return { passed: false, + comparedAgainstApi: false, warning: localUnpack.error || 'Failed to unpack transaction locally', mismatches: ['Local unpack failed'], localUnpack, }; } - // If no API message to compare against, local unpack success is enough + // Nothing to compare against: the message decoded locally, but no second opinion was obtained. + // Not a failure — the transaction is not blocked — but the display must not call it verified. if (!apiMessage) { return { passed: true, + comparedAgainstApi: false, mismatches: [], localUnpack, }; @@ -726,6 +739,7 @@ export function verifyProviderTransaction( return { passed, + comparedAgainstApi: true, warning: passed ? undefined : `Verification failed: ${mismatches.join('; ')}`, mismatches, localUnpack,