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 (
+
+
+ Decoded locally — no second source to check it against
+
+ );
+ }
+
// 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,