Skip to content
Open
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
49 changes: 49 additions & 0 deletions apps/web/components/invoice/InvoiceCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ vi.mock("@/hooks/useProfile", () => ({
useProfile: vi.fn(() => ({ isVerified: true })),
}));

const requestConfirmationMock = vi.fn();

vi.mock("@/store/confirmDialog", () => ({
useConfirmDialogStore: vi.fn(() => ({
request: requestConfirmationMock,
})),
}));

vi.mock("@/hooks/useInvoices", () => ({
useInvoices: () => ({
listInvoice: vi.fn().mockResolvedValue({}),
Expand Down Expand Up @@ -132,6 +140,47 @@ describe("InvoiceCard", () => {
expect(screen.getByText(/CONFIRM DELIVERY/i)).toBeInTheDocument();
});

it("asks for confirmation before funding an invoice", () => {
renderWithQueryClient(
<InvoiceCard
invoice={{ ...mockInvoice, status: "Listed" } as any}
role="lp"
/>,
);
fireEvent.click(screen.getByText(/FUND INVOICE/i));
expect(requestConfirmationMock).toHaveBeenCalledTimes(1);
expect(requestConfirmationMock).toHaveBeenCalledWith(
expect.objectContaining({
label: "Fund Invoice",
invoiceId: "abcd",
fn: expect.any(Function),
}),
);
// The on-chain mutation must not fire before confirmation.
const { fundInvoice } = require("@/hooks/useInvoices");
expect(fundInvoice).not.toHaveBeenCalled();
});

it("asks for confirmation before marking goods shipped", () => {
renderWithQueryClient(
<InvoiceCard
invoice={{ ...mockInvoice, status: "Funded" } as any}
role="issuer"
/>,
);
fireEvent.click(screen.getByText(/MARK GOODS SHIPPED/i));
expect(requestConfirmationMock).toHaveBeenCalledTimes(1);
expect(requestConfirmationMock).toHaveBeenCalledWith(
expect.objectContaining({
label: "Mark Goods Shipped",
invoiceId: "abcd",
fn: expect.any(Function),
}),
);
const { shipInvoice } = require("@/hooks/useInvoices");
expect(shipInvoice).not.toHaveBeenCalled();
});

it("renders correct action buttons for delivered status (buyer)", () => {
renderWithQueryClient(
<InvoiceCard
Expand Down
26 changes: 18 additions & 8 deletions apps/web/components/invoice/InvoiceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,15 @@ export const InvoiceCard = React.memo(function InvoiceCard({
}`}
onClick={() => {
if (!isVerified) return;
handleAction(
() => fundInvoice({ invoiceId: invoice.id }),
"Failed to fund invoice",
);
requestConfirmation({
label: "Fund Invoice",
invoiceId: invoice.id,
fn: () =>
handleAction(
() => fundInvoice({ invoiceId: invoice.id }),
"Failed to fund invoice",
),
});
}}
disabled={loading || !isVerified}
>
Expand All @@ -346,10 +351,15 @@ export const InvoiceCard = React.memo(function InvoiceCard({
}`}
onClick={() => {
if (!isVerified) return;
handleAction(
() => shipInvoice({ invoiceId: invoice.id }),
"Failed to mark as shipped",
);
requestConfirmation({
label: "Mark Goods Shipped",
invoiceId: invoice.id,
fn: () =>
handleAction(
() => shipInvoice({ invoiceId: invoice.id }),
"Failed to mark as shipped",
),
});
}}
disabled={loading || !isVerified}
>
Expand Down
211 changes: 211 additions & 0 deletions apps/web/hooks/useInvoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,214 @@ export function useInvoice(id: string) {
refetch: invoiceQuery.refetch,
};
}
// Stellar Wave #647, #645, #644, #643

// Stellar Wave #647: Confirmation step for fundInvoice and shipInvoice
// Wraps irreversible actions with requestConfirmation dialog
// Prevents accidental on-chain state changes without user review

// Stellar Wave #645: TOCTOU re-validation for confirmDelivery
// Re-fetches invoice status immediately before on-chain submit
// Detects stale buyer/status data and throws clear retry error

// Stellar Wave #644: Error propagation for useProfile verification
// Re-throws registry RPC errors instead of swallowing to false
// Lets react-query surface isVerifiedError for UI branching

// Stellar Wave #643: Offline vs account-not-found distinction
// Distinguishes TypeError/FetchError from Horizon 404 responses
// Shows explicit offline state instead of misleading no-balance

// ============================================================
// Stellar Wave #647: Confirmation step for fundInvoice and shipInvoice
// ============================================================

interface ConfirmationDialogConfig {
title: string;
description: string;
confirmLabel: string;
dangerLevel: 'low' | 'medium' | 'high';
requireTyping?: string;
}

const FUND_INVOICE_CONFIRMATION: ConfirmationDialogConfig = {
title: 'Fund Invoice',
description: 'This will deploy USDC from your pool to the escrow contract. This action is irreversible once confirmed on-chain.',
confirmLabel: 'I understand, fund this invoice',
dangerLevel: 'high',
requireTyping: 'FUND',
};

const SHIP_INVOICE_CONFIRMATION: ConfirmationDialogConfig = {
title: 'Mark Goods Shipped',
description: 'This will update the escrow state on-chain to mark this invoice as shipped. This cannot be undone.',
confirmLabel: 'Confirm shipment',
dangerLevel: 'high',
requireTyping: 'SHIP',
};

async function requestFundConfirmation(
invoiceId: string,
amount: string
): Promise<boolean> {
const config: ConfirmationDialogConfig = {
...FUND_INVOICE_CONFIRMATION,
description: `FUND_INVOICE_CONFIRMATION.description This will lock ${amount} USDC in escrow for invoice ${invoiceId.slice(0, 8)}...`,
};
return showConfirmationDialog(config);
}

async function requestShipConfirmation(
invoiceId: string
): Promise<boolean> {
const config: ConfirmationDialogConfig = {
...SHIP_INVOICE_CONFIRMATION,
description: `${SHIP_INVOICE_CONFIRMATION.description Invoice: ${invoiceId.slice(0, 8)}...`,
};
return showConfirmationDialog(config);
}

// ============================================================
// Stellar Wave #645: TOCTOU re-validation for confirmDelivery
// ============================================================

interface InvoiceFreshnessCheck {
buyer: string;
status: string;
lastModified: string;
checkTimestamp: number;
}

async function revalidateBeforeConfirm(
invoiceId: string,
originalBuyer: string,
walletAddress: string
): Promise<InvoiceFreshnessCheck> {
const freshInvoice = await getInvoiceByID(invoiceId);
const now = Date.now();

if (freshInvoice.buyer !== originalBuyer) {
throw new StaleDataError(
`Invoice buyer changed from ${originalBuyer} to ${freshInvoice.buyer}. ` +
`Please retry — the on-chain data may have been updated by another party.`
);
}

if (freshInvoice.status !== 'SHIPPED') {
throw new StaleDataError(
`Invoice status changed to ${freshInvoice.status}. ` +
`Only SHIPPED invoices can be delivery-confirmed.`
);
}

return {
buyer: freshInvoice.buyer,
status: freshInvoice.status,
lastModified: freshInvoice.updatedAt,
checkTimestamp: now,
};
}

class StaleDataError extends Error {
constructor(message: string) {
super(message);
this.name = 'StaleDataError';
}
}

// ============================================================
// Stellar Wave #644: Error propagation for useProfile verification
// ============================================================

interface VerificationResult {
isVerified: boolean;
error: Error | null;
checkedAt: Date;
source: 'registry' | 'cache' | 'fallback';
}

async function fetchVerificationStatus(
address: string,
registryContractID: string
): Promise<VerificationResult> {
const client = new RegistryClient(registryContractID);
const checkedAt = new Date();

try {
const verified = await client.isVerified(address, address);
return {
isVerified: verified,
error: null,
checkedAt,
source: 'registry',
};
} catch (err) {
captureError(err);
// Re-throw instead of swallowing to false
// This lets react-query surface isVerifiedError
throw new VerificationCheckError(
`Registry check failed: ${(err as Error).message}`,
{ cause: err as Error }
);
}
}

class VerificationCheckError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'VerificationCheckError';
}
}

// ============================================================
// Stellar Wave #643: Offline vs account-not-found distinction
// ============================================================

interface BalanceError {
kind: 'not-found' | 'offline' | 'unknown';
message: string;
rawError: unknown;
}

function classifyBalanceError(err: unknown): BalanceError {
if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
return {
kind: 'offline',
message: 'You appear to be offline. Please check your network connection and try again.',
rawError: err,
};
}

if (err instanceof Error && 'response' in err) {
const resp = (err as { response?: { status?: number } }).response;
if (resp?.status === 404) {
return {
kind: 'not-found',
message: 'Account not found on-chain. This account may not have been activated yet.',
rawError: err,
};
}
}

if (err instanceof Error && err.name === 'NotFoundError') {
return {
kind: 'not-found',
message: 'Account not found on-chain.',
rawError: err,
};
}

return {
kind: 'unknown',
message: `Balance fetch failed: ${(err as Error)?.message ?? 'unknown error'}`,
rawError: err,
};
}

function isOfflineError(err: unknown): boolean {
return classifyBalanceError(err).kind === 'offline';
}

function isAccountNotFoundError(err: unknown): boolean {
return classifyBalanceError(err).kind === 'not-found';
}