From 5461330e5ff27dcdce4f594df5c8a392aa3e9b15 Mon Sep 17 00:00:00 2001 From: AdaBliss Date: Tue, 29 Sep 2026 09:36:51 -0700 Subject: [PATCH] feat(hooks): add useHighValueCargoApproval hook for multi-sig transactions - Add cargoApprovalService for GET /api/escrow/{id}/approvals and POST /api/escrow/{id}/approvals/signatures, mapping 403/409/410 to typed error codes - Add CargoApproval types with weighted signers and approval window - Add useHighValueCargoApproval hook: resolves signers and weights, verifies the weighted threshold, checks the connected wallet is an authorized signer that has not signed, signs via Freighter and exposes isThresholdMet for submit enablement - Block signing once the approval window expires, whether reported by the backend or reached while the view is open - Add service and hook unit tests --- .../fixtures/cargoApprovalApiResponses.ts | 43 +++ .../useHighValueCargoApproval.test.tsx | 350 ++++++++++++++++++ hooks/useHighValueCargoApproval.ts | 230 ++++++++++++ .../__tests__/cargoApprovalService.test.ts | 96 +++++ services/cargoApprovalService.ts | 85 +++++ types/cargoApproval.ts | 44 +++ 6 files changed, 848 insertions(+) create mode 100644 hooks/__tests__/fixtures/cargoApprovalApiResponses.ts create mode 100644 hooks/__tests__/useHighValueCargoApproval.test.tsx create mode 100644 hooks/useHighValueCargoApproval.ts create mode 100644 services/__tests__/cargoApprovalService.test.ts create mode 100644 services/cargoApprovalService.ts create mode 100644 types/cargoApproval.ts diff --git a/hooks/__tests__/fixtures/cargoApprovalApiResponses.ts b/hooks/__tests__/fixtures/cargoApprovalApiResponses.ts new file mode 100644 index 0000000..4cecff6 --- /dev/null +++ b/hooks/__tests__/fixtures/cargoApprovalApiResponses.ts @@ -0,0 +1,43 @@ +import type { CargoApproval } from '@/types/cargoApproval'; + +export const SIGNER_FLEET_MANAGER = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; +export const SIGNER_SHIPPER = 'GBFRZ3E3AAFGBY6JIAX6OFEBW5JVE7KMU6MVADXXWTRLZ25TK22ENLCW'; +export const SIGNER_INSURER = 'GCBAWFKS45CQA4C4BUIXM57Z2YKSP6HPIWXXUF7ZJVM6WTLNRAWML26O'; +export const NON_SIGNER = 'GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37'; + +/** Threshold 3: fleet manager (2) already signed, shipper (1) and insurer (1) outstanding. */ +export const pendingApprovalFixture: CargoApproval = { + escrowId: 'escrow-hv-001', + threshold: 3, + signers: [ + { + publicKey: SIGNER_FLEET_MANAGER, + weight: 2, + hasSigned: true, + signedAt: '2026-09-28T08:00:00.000Z', + label: 'Fleet manager', + }, + { publicKey: SIGNER_SHIPPER, weight: 1, hasSigned: false, signedAt: null, label: 'Shipper' }, + { publicKey: SIGNER_INSURER, weight: 1, hasSigned: false, signedAt: null, label: 'Insurer' }, + ], + transactionXdr: 'AAAAAgAAAAB8k0hvYl5yTHV0cmFuc2FjdGlvbkVudmVsb3Bl', + expiresAt: '2099-01-01T00:00:00.000Z', + status: 'pending', +}; + +/** Shipper's signature brings the combined weight to 3 and meets the threshold. */ +export const thresholdMetApprovalFixture: CargoApproval = { + ...pendingApprovalFixture, + signers: pendingApprovalFixture.signers.map((signer) => + signer.publicKey === SIGNER_SHIPPER + ? { ...signer, hasSigned: true, signedAt: '2026-09-28T09:00:00.000Z' } + : signer, + ), + status: 'threshold_met', +}; + +export const expiredApprovalFixture: CargoApproval = { + ...pendingApprovalFixture, + expiresAt: '2020-01-01T00:00:00.000Z', + status: 'expired', +}; diff --git a/hooks/__tests__/useHighValueCargoApproval.test.tsx b/hooks/__tests__/useHighValueCargoApproval.test.tsx new file mode 100644 index 0000000..a8426cb --- /dev/null +++ b/hooks/__tests__/useHighValueCargoApproval.test.tsx @@ -0,0 +1,350 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createElement, type ReactNode } from 'react'; +import { toast } from 'sonner'; +import { useHighValueCargoApproval } from '@/hooks/useHighValueCargoApproval'; +import { + CargoApprovalServiceError, + cargoApprovalService, +} from '@/services/cargoApprovalService'; +import { freighterService } from '@/services/freighterService'; +import { useWalletStore } from '@/store/walletStore'; +import { + NON_SIGNER, + SIGNER_FLEET_MANAGER, + SIGNER_INSURER, + SIGNER_SHIPPER, + expiredApprovalFixture, + pendingApprovalFixture, + thresholdMetApprovalFixture, +} from './fixtures/cargoApprovalApiResponses'; + +jest.mock('@/services/cargoApprovalService', () => { + const actual = jest.requireActual('@/services/cargoApprovalService'); + return { + ...actual, + cargoApprovalService: { + getApproval: jest.fn(), + submitSignature: jest.fn(), + }, + }; +}); + +jest.mock('@/services/freighterService', () => ({ + freighterService: { + getPublicKey: jest.fn(), + signTransaction: jest.fn(), + }, +})); + +jest.mock('sonner', () => ({ + toast: { success: jest.fn(), error: jest.fn(), info: jest.fn() }, +})); + +const mockGetApproval = cargoApprovalService.getApproval as jest.Mock; +const mockSubmitSignature = cargoApprovalService.submitSignature as jest.Mock; +const mockGetPublicKey = freighterService.getPublicKey as jest.Mock; +const mockSignTransaction = freighterService.signTransaction as jest.Mock; + +const ESCROW_ID = 'escrow-hv-001'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +function connectAs(address: string) { + act(() => { + useWalletStore.getState().setWallet(address, 0); + }); +} + +async function renderLoaded() { + const hook = renderHook(() => useHighValueCargoApproval(ESCROW_ID), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(hook.result.current.approval).not.toBeNull()); + return hook; +} + +describe('useHighValueCargoApproval', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + act(() => { + useWalletStore.getState().clearWalletState(); + }); + mockGetApproval.mockResolvedValue(pendingApprovalFixture); + mockGetPublicKey.mockResolvedValue(SIGNER_SHIPPER); + mockSignTransaction.mockResolvedValue('signed-xdr'); + mockSubmitSignature.mockResolvedValue(thresholdMetApprovalFixture); + }); + + describe('signer resolution', () => { + it('fetches the approval for the escrow and tracks loading', async () => { + const { result } = renderHook(() => useHighValueCargoApproval(ESCROW_ID), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockGetApproval).toHaveBeenCalledWith(ESCROW_ID, expect.any(AbortSignal)); + }); + + it('returns every signer with weight and approved status', async () => { + connectAs(SIGNER_SHIPPER); + const { result } = await renderLoaded(); + + expect(result.current.signers).toEqual([ + expect.objectContaining({ publicKey: SIGNER_FLEET_MANAGER, weight: 2, approved: true }), + expect.objectContaining({ + publicKey: SIGNER_SHIPPER, + weight: 1, + approved: false, + isCurrentUser: true, + }), + expect.objectContaining({ publicKey: SIGNER_INSURER, weight: 1, approved: false }), + ]); + }); + + it('does not query without an escrow id', () => { + renderHook(() => useHighValueCargoApproval(''), { wrapper: createWrapper() }); + expect(mockGetApproval).not.toHaveBeenCalled(); + }); + + it('exposes load errors', async () => { + mockGetApproval.mockRejectedValue(new Error('Unable to load approval details.')); + const { result } = renderHook(() => useHighValueCargoApproval(ESCROW_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.error).toBe('Unable to load approval details.')); + expect(result.current.canSign).toBe(false); + }); + }); + + describe('threshold verification', () => { + it('sums signed weights rather than counting signatures', async () => { + const { result } = await renderLoaded(); + + expect(result.current.threshold).toBe(3); + expect(result.current.currentWeight).toBe(2); + expect(result.current.signatureCount).toBe(1); + expect(result.current.isThresholdMet).toBe(false); + }); + + it('is met once the combined weight reaches the threshold', async () => { + mockGetApproval.mockResolvedValue({ ...thresholdMetApprovalFixture, status: 'pending' }); + const { result } = await renderLoaded(); + + expect(result.current.currentWeight).toBe(3); + expect(result.current.isThresholdMet).toBe(true); + }); + + it('is not met for a zero threshold with no signatures', async () => { + mockGetApproval.mockResolvedValue({ + ...pendingApprovalFixture, + threshold: 0, + signers: pendingApprovalFixture.signers.map((s) => ({ ...s, hasSigned: false })), + }); + const { result } = await renderLoaded(); + + expect(result.current.isThresholdMet).toBe(false); + }); + + it('trusts a threshold_met status from the escrow service', async () => { + mockGetApproval.mockResolvedValue({ ...pendingApprovalFixture, status: 'threshold_met' }); + const { result } = await renderLoaded(); + + expect(result.current.isThresholdMet).toBe(true); + }); + }); + + describe('signer authorization', () => { + it('allows an authorized signer who has not signed', async () => { + connectAs(SIGNER_SHIPPER); + const { result } = await renderLoaded(); + + expect(result.current.isAuthorizedSigner).toBe(true); + expect(result.current.hasSigned).toBe(false); + expect(result.current.canSign).toBe(true); + expect(result.current.signBlockedReason).toBeNull(); + }); + + it('blocks a wallet that is not in the signer set', async () => { + connectAs(NON_SIGNER); + const { result } = await renderLoaded(); + + expect(result.current.isAuthorizedSigner).toBe(false); + expect(result.current.canSign).toBe(false); + expect(result.current.signBlockedReason).toBe('unauthorized'); + + let ok = true; + await act(async () => { + ok = await result.current.sign(); + }); + expect(ok).toBe(false); + expect(mockSignTransaction).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith( + 'Your wallet is not an authorized signer for this cargo.', + ); + }); + + it('blocks signing without a connected wallet', async () => { + const { result } = await renderLoaded(); + + expect(result.current.signBlockedReason).toBe('wallet_disconnected'); + expect(result.current.canSign).toBe(false); + }); + + it('rejects a Freighter account that differs from the connected wallet', async () => { + connectAs(SIGNER_SHIPPER); + mockGetPublicKey.mockResolvedValue(SIGNER_INSURER); + const { result } = await renderLoaded(); + + let ok = true; + await act(async () => { + ok = await result.current.sign(); + }); + + expect(ok).toBe(false); + expect(mockSignTransaction).not.toHaveBeenCalled(); + expect(mockSubmitSignature).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith( + 'The Freighter account does not match your connected wallet.', + ); + }); + }); + + describe('signing', () => { + it('signs the envelope with Freighter, submits it and applies the new state', async () => { + connectAs(SIGNER_SHIPPER); + const { result } = await renderLoaded(); + + let ok = false; + await act(async () => { + ok = await result.current.sign(); + }); + + expect(ok).toBe(true); + expect(mockSignTransaction).toHaveBeenCalledWith(pendingApprovalFixture.transactionXdr); + expect(mockSubmitSignature).toHaveBeenCalledWith(ESCROW_ID, { + signerPublicKey: SIGNER_SHIPPER, + signedTransactionXdr: 'signed-xdr', + }); + await waitFor(() => expect(result.current.isThresholdMet).toBe(true)); + expect(result.current.hasSigned).toBe(true); + expect(result.current.canSign).toBe(false); + expect(toast.success).toHaveBeenCalledWith('Signature recorded'); + }); + + it('surfaces a rejected Freighter prompt as a sign error', async () => { + connectAs(SIGNER_SHIPPER); + mockSignTransaction.mockRejectedValue(new Error('User declined access')); + const { result } = await renderLoaded(); + + await act(async () => { + await result.current.sign(); + }); + + await waitFor(() => expect(result.current.signError).toBe('User declined access')); + expect(mockSubmitSignature).not.toHaveBeenCalled(); + }); + }); + + describe('already-signed state', () => { + it('reports hasSigned and blocks a second signature', async () => { + connectAs(SIGNER_FLEET_MANAGER); + const { result } = await renderLoaded(); + + expect(result.current.hasSigned).toBe(true); + expect(result.current.signBlockedReason).toBe('already_signed'); + + let ok = true; + await act(async () => { + ok = await result.current.sign(); + }); + expect(ok).toBe(false); + expect(mockGetPublicKey).not.toHaveBeenCalled(); + }); + + it('resyncs when the backend reports the signature already exists', async () => { + connectAs(SIGNER_SHIPPER); + mockSubmitSignature.mockRejectedValue( + new CargoApprovalServiceError('Signature already recorded', 409), + ); + const { result } = await renderLoaded(); + mockGetApproval.mockResolvedValue(thresholdMetApprovalFixture); + + await act(async () => { + await result.current.sign(); + }); + + expect(toast.info).toHaveBeenCalledWith('You have already signed this approval.'); + await waitFor(() => expect(result.current.hasSigned).toBe(true)); + }); + }); + + describe('expired approval windows', () => { + it('blocks signing when the service reports the window expired', async () => { + connectAs(SIGNER_SHIPPER); + mockGetApproval.mockResolvedValue(expiredApprovalFixture); + const { result } = await renderLoaded(); + + expect(result.current.isExpired).toBe(true); + expect(result.current.signBlockedReason).toBe('expired'); + expect(result.current.canSign).toBe(false); + }); + + it('treats a past deadline as expired even if the status is stale', async () => { + connectAs(SIGNER_SHIPPER); + mockGetApproval.mockResolvedValue({ + ...pendingApprovalFixture, + expiresAt: '2020-01-01T00:00:00.000Z', + }); + const { result } = await renderLoaded(); + + expect(result.current.isExpired).toBe(true); + expect(result.current.canSign).toBe(false); + }); + + it('expires while the view is open when the deadline passes', async () => { + connectAs(SIGNER_SHIPPER); + mockGetApproval.mockResolvedValue({ + ...pendingApprovalFixture, + expiresAt: new Date(Date.now() + 150).toISOString(), + }); + const { result } = await renderLoaded(); + + expect(result.current.isExpired).toBe(false); + expect(result.current.canSign).toBe(true); + await waitFor(() => expect(result.current.isExpired).toBe(true)); + expect(result.current.signBlockedReason).toBe('expired'); + }); + + it('handles the backend rejecting a signature for an expired window', async () => { + connectAs(SIGNER_SHIPPER); + mockSubmitSignature.mockRejectedValue( + new CargoApprovalServiceError('Approval window closed', 410), + ); + const { result } = await renderLoaded(); + mockGetApproval.mockResolvedValue(expiredApprovalFixture); + + let ok = true; + await act(async () => { + ok = await result.current.sign(); + }); + + expect(ok).toBe(false); + expect(toast.error).toHaveBeenCalledWith('This approval window has expired.'); + await waitFor(() => expect(result.current.isExpired).toBe(true)); + }); + }); +}); diff --git a/hooks/useHighValueCargoApproval.ts b/hooks/useHighValueCargoApproval.ts new file mode 100644 index 0000000..a9fda2e --- /dev/null +++ b/hooks/useHighValueCargoApproval.ts @@ -0,0 +1,230 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { + CargoApprovalServiceError, + cargoApprovalService, +} from '@/services/cargoApprovalService'; +import { freighterService } from '@/services/freighterService'; +import { useWalletStore } from '@/store/walletStore'; +import type { CargoApproval } from '@/types/cargoApproval'; + +/** Poll while co-signers are still signing so progress stays current. */ +export const APPROVAL_POLL_INTERVAL_MS = 15_000; +/** setTimeout overflows above this delay (about 24.8 days). */ +const MAX_TIMEOUT_MS = 2_147_483_647; + +export const cargoApprovalKeys = { + approval: (escrowId: string) => ['highValueCargoApproval', escrowId] as const, +}; + +export interface ApprovalSigner { + publicKey: string; + weight: number; + approved: boolean; + signedAt: string | null; + label?: string; + /** True when this signer is the connected wallet. */ + isCurrentUser: boolean; +} + +export type SignBlockedReason = + | 'loading' + | 'wallet_disconnected' + | 'unauthorized' + | 'already_signed' + | 'threshold_met' + | 'expired' + | 'closed'; + +export interface UseHighValueCargoApprovalResult { + approval: CargoApproval | null; + signers: ApprovalSigner[]; + threshold: number; + /** Combined weight of the signatures collected so far. */ + currentWeight: number; + signatureCount: number; + isThresholdMet: boolean; + isExpired: boolean; + isAuthorizedSigner: boolean; + hasSigned: boolean; + canSign: boolean; + /** Why signing is unavailable, or null when the user can sign. */ + signBlockedReason: SignBlockedReason | null; + sign: () => Promise; + isSigning: boolean; + signError: string | null; + isLoading: boolean; + error: string | null; + refetch: () => void; +} + +const BLOCKED_MESSAGES: Record = { + loading: 'Approval details are still loading.', + wallet_disconnected: 'Connect your wallet to sign this approval.', + unauthorized: 'Your wallet is not an authorized signer for this cargo.', + already_signed: 'You have already signed this approval.', + threshold_met: 'The approval threshold has already been met.', + expired: 'This approval window has expired.', + closed: 'This approval is no longer accepting signatures.', +}; + +/** + * Tracks wall-clock time just precisely enough to flip `isExpired` at the + * deadline, using a single timer instead of a ticking interval. + */ +function useDeadlinePassed(deadlineMs: number | null): boolean { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (deadlineMs === null || deadlineMs <= now) return; + const delay = Math.min(Math.max(deadlineMs - Date.now(), 0), MAX_TIMEOUT_MS); + const id = setTimeout(() => setNow(Date.now()), delay); + return () => clearTimeout(id); + }, [deadlineMs, now]); + + return deadlineMs !== null && deadlineMs <= now; +} + +/** + * useHighValueCargoApproval — multi-signature approval flow for high-value cargo. + * + * Follows the Component → Hook → Service pattern: + * Approval UI → useHighValueCargoApproval → cargoApprovalService / freighterService + * + * Resolves the signer set and weights from the escrow API, verifies the + * connected wallet is an authorized signer that has not yet signed, collects + * its signature through Freighter and reports whether the weighted threshold + * is met. Expired approval windows block signing, both when the backend + * reports them and when the deadline passes while the view is open. + */ +export function useHighValueCargoApproval(escrowId: string): UseHighValueCargoApprovalResult { + const queryClient = useQueryClient(); + const walletAddress = useWalletStore((state) => state.address); + const isConnected = useWalletStore((state) => state.isConnected); + const queryKey = cargoApprovalKeys.approval(escrowId); + + const query = useQuery({ + queryKey, + queryFn: ({ signal }) => cargoApprovalService.getApproval(escrowId, signal), + enabled: !!escrowId, + refetchInterval: (q) => + q.state.data?.status === 'pending' ? APPROVAL_POLL_INTERVAL_MS : false, + }); + + const approval = query.data ?? null; + const deadlineMs = approval ? Date.parse(approval.expiresAt) : null; + const deadlinePassed = useDeadlinePassed( + deadlineMs !== null && Number.isFinite(deadlineMs) ? deadlineMs : null, + ); + + const signers = useMemo( + () => + (approval?.signers ?? []).map((signer) => ({ + publicKey: signer.publicKey, + weight: signer.weight, + approved: signer.hasSigned, + signedAt: signer.signedAt, + label: signer.label, + isCurrentUser: isConnected && signer.publicKey === walletAddress, + })), + [approval, isConnected, walletAddress], + ); + + const threshold = approval?.threshold ?? 0; + const currentWeight = signers.reduce((sum, s) => (s.approved ? sum + s.weight : sum), 0); + const signatureCount = signers.filter((s) => s.approved).length; + const isThresholdMet = + !!approval && + (approval.status === 'threshold_met' || + approval.status === 'executed' || + (threshold > 0 && currentWeight >= threshold)); + const isExpired = !!approval && (approval.status === 'expired' || deadlinePassed); + + const currentSigner = signers.find((s) => s.isCurrentUser); + const isAuthorizedSigner = !!currentSigner; + const hasSigned = !!currentSigner?.approved; + + let signBlockedReason: SignBlockedReason | null = null; + if (!approval) signBlockedReason = 'loading'; + else if (!isConnected || !walletAddress) signBlockedReason = 'wallet_disconnected'; + else if (isExpired) signBlockedReason = 'expired'; + else if (!isAuthorizedSigner) signBlockedReason = 'unauthorized'; + else if (hasSigned) signBlockedReason = 'already_signed'; + else if (isThresholdMet) signBlockedReason = 'threshold_met'; + else if (approval.status !== 'pending') signBlockedReason = 'closed'; + + const mutation = useMutation({ + mutationFn: async (current: CargoApproval) => { + const publicKey = await freighterService.getPublicKey(); + if (publicKey !== walletAddress) { + throw new CargoApprovalServiceError( + 'The Freighter account does not match your connected wallet.', + null, + ); + } + const signedTransactionXdr = await freighterService.signTransaction(current.transactionXdr); + return cargoApprovalService.submitSignature(escrowId, { + signerPublicKey: publicKey, + signedTransactionXdr, + }); + }, + onSuccess: (updated) => { + queryClient.setQueryData(queryKey, updated); + toast.success('Signature recorded'); + }, + onError: (err: Error) => { + if (err instanceof CargoApprovalServiceError && err.code === 'already_signed') { + toast.info(BLOCKED_MESSAGES.already_signed); + } else if (err instanceof CargoApprovalServiceError && err.code === 'expired') { + toast.error(BLOCKED_MESSAGES.expired); + } else { + toast.error(err.message || 'Failed to sign approval'); + } + // The server state moved on (new signature, expiry, signer change); resync. + void queryClient.invalidateQueries({ queryKey }); + }, + }); + + const { mutateAsync, isPending } = mutation; + const sign = useCallback(async (): Promise => { + if (isPending) return false; + if (signBlockedReason || !approval) { + toast.error(BLOCKED_MESSAGES[signBlockedReason ?? 'loading']); + return false; + } + try { + await mutateAsync(approval); + return true; + } catch { + return false; + } + }, [approval, isPending, mutateAsync, signBlockedReason]); + + const { refetch: refetchQuery } = query; + const refetch = useCallback(() => { + void refetchQuery(); + }, [refetchQuery]); + + return { + approval, + signers, + threshold, + currentWeight, + signatureCount, + isThresholdMet, + isExpired, + isAuthorizedSigner, + hasSigned, + canSign: signBlockedReason === null && !isPending, + signBlockedReason, + sign, + isSigning: isPending, + signError: mutation.error ? mutation.error.message : null, + isLoading: query.isLoading, + error: query.error ? query.error.message : null, + refetch, + }; +} diff --git a/services/__tests__/cargoApprovalService.test.ts b/services/__tests__/cargoApprovalService.test.ts new file mode 100644 index 0000000..ba91afb --- /dev/null +++ b/services/__tests__/cargoApprovalService.test.ts @@ -0,0 +1,96 @@ +import { AxiosError, AxiosHeaders } from 'axios'; +import api from '@/lib/api'; +import { + CargoApprovalServiceError, + cargoApprovalService, +} from '@/services/cargoApprovalService'; +import { + SIGNER_SHIPPER, + pendingApprovalFixture, + thresholdMetApprovalFixture, +} from '@/hooks/__tests__/fixtures/cargoApprovalApiResponses'; + +jest.mock('@/lib/api', () => ({ + __esModule: true, + default: { get: jest.fn(), post: jest.fn() }, +})); + +const mockGet = api.get as jest.Mock; +const mockPost = api.post as jest.Mock; + +function axiosErrorWith(status: number, message: string): AxiosError { + const headers = new AxiosHeaders(); + return new AxiosError('Request failed', 'ERR_BAD_REQUEST', { headers }, null, { + status, + statusText: 'Error', + headers, + config: { headers }, + data: { message }, + }); +} + +describe('cargoApprovalService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getApproval', () => { + it('reads the escrow approvals endpoint and unwraps the envelope', async () => { + mockGet.mockResolvedValue({ data: { success: true, data: pendingApprovalFixture } }); + + await expect(cargoApprovalService.getApproval('escrow-hv-001')).resolves.toEqual( + pendingApprovalFixture, + ); + expect(mockGet).toHaveBeenCalledWith('/escrow/escrow-hv-001/approvals', { + signal: undefined, + }); + }); + + it('encodes the escrow id in the path', async () => { + mockGet.mockResolvedValue({ data: { success: true, data: pendingApprovalFixture } }); + + await cargoApprovalService.getApproval('escrow/../1'); + + expect(mockGet).toHaveBeenCalledWith('/escrow/escrow%2F..%2F1/approvals', { + signal: undefined, + }); + }); + + it('throws the API message for an unsuccessful envelope', async () => { + mockGet.mockResolvedValue({ data: { success: false, message: 'Escrow not found' } }); + + await expect(cargoApprovalService.getApproval('missing')).rejects.toThrow( + 'Escrow not found', + ); + }); + }); + + describe('submitSignature', () => { + const request = { signerPublicKey: SIGNER_SHIPPER, signedTransactionXdr: 'signed-xdr' }; + + it('posts the signed envelope and returns the updated approval', async () => { + mockPost.mockResolvedValue({ data: { success: true, data: thresholdMetApprovalFixture } }); + + await expect( + cargoApprovalService.submitSignature('escrow-hv-001', request), + ).resolves.toEqual(thresholdMetApprovalFixture); + expect(mockPost).toHaveBeenCalledWith('/escrow/escrow-hv-001/approvals/signatures', request); + }); + + it.each([ + [409, 'already_signed'], + [410, 'expired'], + [403, 'unauthorized_signer'], + [500, 'unknown'], + ])('maps HTTP %i to the %s error code', async (status, code) => { + mockPost.mockRejectedValue(axiosErrorWith(status, 'Rejected by escrow service')); + + const error = await cargoApprovalService + .submitSignature('escrow-hv-001', request) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(CargoApprovalServiceError); + expect(error).toMatchObject({ code, status, message: 'Rejected by escrow service' }); + }); + }); +}); diff --git a/services/cargoApprovalService.ts b/services/cargoApprovalService.ts new file mode 100644 index 0000000..01da503 --- /dev/null +++ b/services/cargoApprovalService.ts @@ -0,0 +1,85 @@ +import { isAxiosError } from 'axios'; +import api from '@/lib/api'; +import type { + CargoApproval, + CargoApprovalApiResponse, + SubmitCargoSignatureRequest, +} from '@/types/cargoApproval'; + +/** Backend conflict states the approval flow reacts to. */ +export type CargoApprovalErrorCode = 'already_signed' | 'expired' | 'unauthorized_signer' | 'unknown'; + +const STATUS_CODES: Record = { + 403: 'unauthorized_signer', + 409: 'already_signed', + 410: 'expired', +}; + +export class CargoApprovalServiceError extends Error { + readonly status: number | null; + readonly code: CargoApprovalErrorCode; + + constructor(message: string, status: number | null) { + super(message); + this.name = 'CargoApprovalServiceError'; + this.status = status; + this.code = (status !== null && STATUS_CODES[status]) || 'unknown'; + } +} + +function toServiceError(error: unknown, fallback: string): CargoApprovalServiceError { + if (error instanceof CargoApprovalServiceError) return error; + if (isAxiosError<{ message?: string }>(error)) { + return new CargoApprovalServiceError( + error.response?.data?.message ?? fallback, + error.response?.status ?? null, + ); + } + return new CargoApprovalServiceError(error instanceof Error ? error.message : fallback, null); +} + +function unwrap(response: CargoApprovalApiResponse, fallback: string): T { + if (!response.success || response.data === undefined) { + throw new CargoApprovalServiceError(response.message ?? fallback, null); + } + return response.data; +} + +const approvalsPath = (escrowId: string) => `/escrow/${encodeURIComponent(escrowId)}/approvals`; + +/** + * cargoApprovalService — multi-signature approvals for high-value cargo escrows. + * Hooks call this; components never call this directly. + */ +export const cargoApprovalService = { + /** Loads the signer set, weights, threshold and approval window. */ + async getApproval(escrowId: string, signal?: AbortSignal): Promise { + const fallback = 'Unable to load approval details. Please try again.'; + try { + const { data } = await api.get>( + approvalsPath(escrowId), + { signal }, + ); + return unwrap(data, fallback); + } catch (error) { + throw toServiceError(error, fallback); + } + }, + + /** Submits a signed envelope and returns the updated approval state. */ + async submitSignature( + escrowId: string, + request: SubmitCargoSignatureRequest, + ): Promise { + const fallback = 'Unable to submit your signature. Please try again.'; + try { + const { data } = await api.post>( + `${approvalsPath(escrowId)}/signatures`, + request, + ); + return unwrap(data, fallback); + } catch (error) { + throw toServiceError(error, fallback); + } + }, +}; diff --git a/types/cargoApproval.ts b/types/cargoApproval.ts new file mode 100644 index 0000000..1953c57 --- /dev/null +++ b/types/cargoApproval.ts @@ -0,0 +1,44 @@ +/** + * Multi-signature approval state for high-value cargo escrows, returned by + * GET /api/escrow/{escrowId}/approvals. + * + * Thresholds follow Stellar account semantics: each signer carries a weight and + * the approval is met once the combined weight of collected signatures reaches + * the threshold. + */ + +export type CargoApprovalStatus = 'pending' | 'threshold_met' | 'executed' | 'expired' | 'cancelled'; + +export interface CargoApprovalSigner { + /** Stellar public key (G...) of the signer. */ + publicKey: string; + weight: number; + hasSigned: boolean; + /** ISO 8601 timestamp of the signature, null while outstanding. */ + signedAt: string | null; + /** Optional display label, e.g. "Fleet manager". */ + label?: string; +} + +export interface CargoApproval { + escrowId: string; + /** Combined signer weight required to release the escrow. */ + threshold: number; + signers: CargoApprovalSigner[]; + /** Base64 transaction envelope each signer signs. */ + transactionXdr: string; + /** ISO 8601 end of the approval window. */ + expiresAt: string; + status: CargoApprovalStatus; +} + +export interface SubmitCargoSignatureRequest { + signerPublicKey: string; + signedTransactionXdr: string; +} + +export interface CargoApprovalApiResponse { + success: boolean; + data?: T; + message?: string; +}