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
42 changes: 42 additions & 0 deletions src/components/domain/tx/verification-status.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<VerificationStatus passed comparedAgainstApi />);
expect(screen.getByText(/no tampering detected/i)).toBeInTheDocument();
});

it('does not claim verification when there was nothing to compare against', () => {
render(<VerificationStatus passed comparedAgainstApi={false} />);

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(<VerificationStatus />);
expect(container).toBeEmptyDOMElement();
});

it('blocks loudly on failure in strict mode', () => {
render(<VerificationStatus passed={false} isStrict warning="Quantity differs" />);

expect(screen.getByText(/signing blocked/i)).toBeInTheDocument();
expect(screen.getByText('Quantity differs')).toBeInTheDocument();
});

it('warns rather than blocks when strict mode is off', () => {
render(<VerificationStatus passed={false} isStrict={false} warning="Quantity differs" />);

expect(screen.getByText(/verification warning/i)).toBeInTheDocument();
expect(screen.queryByText(/signing blocked/i)).not.toBeInTheDocument();
});
});
24 changes: 22 additions & 2 deletions src/components/domain/tx/verification-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) */
Expand All @@ -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 {
Expand All @@ -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 (
<div className="flex items-center justify-center gap-1.5 text-xs font-medium text-gray-600">
<FiInfo className="size-4 flex-shrink-0" aria-hidden="true" />
Decoded locally — no second source to check it against
</div>
);
}

// Verification passed — compact inline badge, not a full banner.
if (passed === true) {
return (
Expand Down
2 changes: 2 additions & 0 deletions src/pages/requests/psbt/approve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -394,6 +395,7 @@ export default function ApprovePsbtPage() {
{/* Verification Status (compact badge when passed) */}
<VerificationStatus
passed={verificationPassed}
comparedAgainstApi={verificationComparedAgainstApi}
warning={verificationWarning}
isStrict={isStrictMode}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/pages/requests/transaction/approve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -425,6 +426,7 @@ export default function ApproveTransactionPage() {
{/* Verification Status (compact badge when passed) */}
<VerificationStatus
passed={verificationPassed}
comparedAgainstApi={verificationComparedAgainstApi}
warning={verificationWarning}
isStrict={isStrictMode}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
16 changes: 15 additions & 1 deletion src/utils/blockchain/counterparty/unpack/providerVerify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -590,6 +599,7 @@ export function verifyProviderTransaction(
if (!opReturnData || !isCounterpartyData(opReturnData)) {
return {
passed: undefined,
comparedAgainstApi: false,
mismatches: [],
warning: undefined,
};
Expand All @@ -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,
};
Expand Down Expand Up @@ -726,6 +739,7 @@ export function verifyProviderTransaction(

return {
passed,
comparedAgainstApi: true,
warning: passed ? undefined : `Verification failed: ${mismatches.join('; ')}`,
mismatches,
localUnpack,
Expand Down
Loading