diff --git a/src/app/invoice/[id]/cosigners/page.tsx b/src/app/invoice/[id]/cosigners/page.tsx index 06ee3c4d..62d66700 100644 --- a/src/app/invoice/[id]/cosigners/page.tsx +++ b/src/app/invoice/[id]/cosigners/page.tsx @@ -1,11 +1,14 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; -import { useParams, useRouter } from "next/navigation"; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { useParams } from "next/navigation"; import Link from "next/link"; import { QRCodeCanvas } from "qrcode.react"; import { useInvoiceStream } from "@/hooks/useInvoiceStream"; import type { CoSigner, SplitMeta } from "@/hooks/useSplitCalculator"; +import { fetchInvoice } from "@/lib/api/invoices"; +import CoSignerStatus from "@/components/invoice/CoSignerStatus"; +import CopyLinkButton from "@/components/invoice/CopyLinkButton"; function truncateAddress(addr: string, chars = 6): string { if (!addr || addr.length <= chars * 2) return addr; @@ -14,8 +17,7 @@ function truncateAddress(addr: string, chars = 6): string { export default function CoSignersPage() { const { id } = useParams<{ id: string }>(); - const router = useRouter(); - const { invoice, isConnected: streamConnected } = useInvoiceStream(id); + const { invoice, latestEvent, isConnected: streamConnected } = useInvoiceStream(id); const [connectedAddress, setConnectedAddress] = useState(null); const [splitMeta, setSplitMeta] = useState(null); @@ -23,10 +25,10 @@ export default function CoSignersPage() { const [threshold, setThreshold] = useState(1); const [loading, setLoading] = useState(true); const [approving, setApproving] = useState(false); - const [approveSuccess, setApproveSuccess] = useState(false); const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(null); const [showQRModal, setShowQRModal] = useState(false); - const [copied, setCopied] = useState(false); + const lastEventRef = useRef(null); // Fetch connected wallet useEffect(() => { @@ -42,22 +44,23 @@ export default function CoSignersPage() { checkWallet(); }, []); - // Fetch splitMeta and cosigners + // Fetch splitMeta and cosigners via API helper const loadData = useCallback(async () => { try { - const res = await fetch(`/api/invoices/${id}`); - if (res.ok) { - const data = await res.json(); - if (data.splitMeta) { - setSplitMeta(data.splitMeta); - if (data.splitMeta.cosigners && data.splitMeta.cosigners.length > 0) { - setCosigners(data.splitMeta.cosigners); - setThreshold(data.splitMeta.cosignerThreshold || 1); - } - } + const data = await fetchInvoice(id); + const meta: SplitMeta | null = data?.splitMeta ?? null; + setSplitMeta(meta); + if (meta?.cosigners && meta.cosigners.length > 0) { + setCosigners(meta.cosigners); + setThreshold(meta.cosignerThreshold || 1); + } else { + setCosigners([]); + setThreshold(meta?.cosignerThreshold || 1); } + setLoadError(null); } catch (e) { console.error("Error loading cosigners:", e); + setLoadError(e instanceof Error ? e.message : "Failed to load co-signers"); } finally { setLoading(false); } @@ -67,6 +70,17 @@ export default function CoSignersPage() { loadData(); }, [loadData]); + // Real-time refresh on stream events / invoice changes + useEffect(() => { + if (!latestEvent || latestEvent === lastEventRef.current) return; + lastEventRef.current = latestEvent; + loadData(); + }, [latestEvent, loadData]); + + useEffect(() => { + if (invoice) loadData(); + }, [invoice, loadData]); + // Connect wallet helper const handleConnectWallet = async () => { try { @@ -90,9 +104,18 @@ export default function CoSignersPage() { setError(null); try { - // Sign a message or approval transaction with Freighter - const { signAuthEntry } = await import("@stellar/freighter-api").catch(() => ({} as any)); - // In case signAuthEntry is available or signTransaction + // Sign the approval payload with Freighter + const freighter = await import("@stellar/freighter-api"); + const message = `Approve invoice ${id} as co-signer ${connectedAddress}`; + const encoded = Buffer.from(message, "utf8").toString("base64"); + const signed = await freighter.signBlob(encoded, { + accountToSign: connectedAddress, + }); + if (!signed || signed.error || !signed.signedBlob) { + throw new Error( + typeof signed?.error === "string" ? signed.error : "Signature was rejected" + ); + } const timestamp = new Date().toISOString(); const updated = cosigners.map((c) => @@ -129,7 +152,7 @@ export default function CoSignersPage() { } } - setApproveSuccess(true); + await loadData(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to record approval"); } finally { @@ -139,14 +162,6 @@ export default function CoSignersPage() { const shareUrl = typeof window !== "undefined" ? window.location.href : ""; - const handleCopyLink = () => { - if (navigator.clipboard) { - navigator.clipboard.writeText(shareUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2500); - } - }; - const approvedCount = cosigners.filter((c) => c.approved).length; const isThresholdMet = cosigners.length > 0 && approvedCount >= threshold; const isCosigner = Boolean(connectedAddress && cosigners.some((c) => c.address === connectedAddress)); @@ -181,15 +196,8 @@ export default function CoSignersPage() { {/* Share buttons */} -
- +
+
{/* Co-Signers List */} -
+

Designated Co-Signers ({cosigners.length})

- {cosigners.length === 0 ? ( + {loading ? ( +
+ + + + + Loading co-signers… +
+ ) : loadError ? ( +
+

{loadError}

+ +
+ ) : cosigners.length === 0 ? (

No co-signers were specified for this invoice.

) : ( -
- {cosigners.map((signer, index) => { - const isYou = connectedAddress === signer.address; - return ( -
-
-
- - {signer.address} - - {isYou && ( - - YOU - - )} -
- {signer.approvedAt && ( -

- Approved on {new Date(signer.approvedAt).toLocaleString()} -

- )} -
- -
- {signer.approved ? ( - - ✓ Approved - - ) : ( - - ⏳ Pending - - )} -
-
- ); - })} -
+ )}
@@ -382,13 +373,11 @@ export default function CoSignersPage() {

- + />
); }; diff --git a/src/components/CoSignerStatus.tsx b/src/components/CoSignerStatus.tsx new file mode 100644 index 00000000..dd92c15e --- /dev/null +++ b/src/components/CoSignerStatus.tsx @@ -0,0 +1,139 @@ +'use client'; + +import React from 'react'; + +export interface CoSigner { + address: string; + approved: boolean; + approvedAt?: string | number | Date | null; +} + +export interface CoSignerStatusProps { + cosigners: CoSigner[]; + threshold: number; + connectedAddress?: string | null; + approving?: boolean; + onApprove?: (address: string) => void | Promise; + error?: string | null; +} + +function truncateAddress(address: string): string { + if (!address) return ''; + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function formatApprovedAt(value: string | number | Date): string { + try { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString(); + } catch { + return String(value); + } +} + +export default function CoSignerStatus({ + cosigners, + threshold, + connectedAddress, + approving = false, + onApprove, + error, +}: CoSignerStatusProps) { + const approvedCount = cosigners.filter((c) => c.approved).length; + const thresholdMet = approvedCount >= threshold; + const normalizedConnected = connectedAddress?.trim() ?? ''; + + return ( +
+
+

+ Co-signer approvals +

+ + {thresholdMet + ? 'Threshold met' + : `${approvedCount} of ${threshold} approved`} + +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {cosigners.length === 0 ? ( +

No co-signers required.

+ ) : ( +
    + {cosigners.map((cosigner) => { + const isConnected = + normalizedConnected.length > 0 && + normalizedConnected === cosigner.address; + const canApprove = + isConnected && !cosigner.approved && !approving && !!onApprove; + + return ( +
  • +
    + + {truncateAddress(cosigner.address)} + +
    + + {cosigner.approved ? 'Approved' : 'Pending'} + + {cosigner.approved && cosigner.approvedAt ? ( + + {formatApprovedAt(cosigner.approvedAt)} + + ) : null} +
    +
    + +
    + {canApprove ? ( + + ) : null} +
    +
  • + ); + })} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/__tests__/CoSignerStatus.test.tsx b/src/components/__tests__/CoSignerStatus.test.tsx new file mode 100644 index 00000000..660a312d --- /dev/null +++ b/src/components/__tests__/CoSignerStatus.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { CoSignerStatus } from '../CoSignerStatus'; + +describe('CoSignerStatus', () => { + const defaultProps = { + coSigners: [ + { address: 'GAAAA1111', approved: false }, + { address: 'GBBBB2222', approved: true }, + { address: 'GCCCC3333', approved: false }, + ], + threshold: 2, + connectedAddress: null as string | null, + onApprove: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all co-signers with pending and approved states', () => { + render(); + + expect(screen.getByText('GAAAA1111')).toBeInTheDocument(); + expect(screen.getByText('GBBBB2222')).toBeInTheDocument(); + expect(screen.getByText('GCCCC3333')).toBeInTheDocument(); + + expect(screen.getAllByText(/pending/i)).toHaveLength(2); + expect(screen.getAllByText(/approved/i)).toHaveLength(1); + }); + + it('does not show threshold-met indicator when approved count is below threshold', () => { + render(); + + expect(screen.queryByText(/threshold met/i)).not.toBeInTheDocument(); + }); + + it('shows threshold-met indicator when approved count meets threshold', () => { + render( + + ); + + expect(screen.getByText(/threshold met/i)).toBeInTheDocument(); + }); + + it('shows threshold-met indicator when approved count exceeds threshold', () => { + render( + + ); + + expect(screen.getByText(/threshold met/i)).toBeInTheDocument(); + }); + + it('enables Approve button when connected address matches a pending co-signer', () => { + render( + + ); + + const approveButton = screen.getByRole('button', { name: /approve/i }); + expect(approveButton).toBeEnabled(); + }); + + it('disables Approve button when wallet is not connected', () => { + render(); + + const approveButton = screen.getByRole('button', { name: /approve/i }); + expect(approveButton).toBeDisabled(); + }); + + it('disables Approve button when connected address is not a co-signer', () => { + render( + + ); + + const approveButton = screen.getByRole('button', { name: /approve/i }); + expect(approveButton).toBeDisabled(); + }); + + it('disables Approve button when connected co-signer has already approved', () => { + render( + + ); + + const approveButton = screen.getByRole('button', { name: /approve/i }); + expect(approveButton).toBeDisabled(); + }); + + it('calls onApprove with the connected address when Approve is clicked', () => { + const onApprove = jest.fn(); + render( + + ); + + const approveButton = screen.getByRole('button', { name: /approve/i }); + fireEvent.click(approveButton); + + expect(onApprove).toHaveBeenCalledTimes(1); + expect(onApprove).toHaveBeenCalledWith('GAAAA1111'); + }); + + it('does not call onApprove when Approve button is disabled', () => { + const onApprove = jest.fn(); + render( + + ); + + const approveButton = screen.getByRole('button', { name: /approve/i }); + fireEvent.click(approveButton); + + expect(onApprove).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/hooks/useInvoiceForm.ts b/src/hooks/useInvoiceForm.ts index f36ae2d9..ac4bd49f 100644 --- a/src/hooks/useInvoiceForm.ts +++ b/src/hooks/useInvoiceForm.ts @@ -1,21 +1,213 @@ 'use client'; +import { useCallback, useMemo, useState } from 'react'; import { useInvoiceMeta } from './useInvoiceMeta'; import { useLineItems } from './useLineItems'; export type { LineItem } from './useLineItems'; +/** + * Validates a Stellar public key (ed25519 account address). + * Format: 'G' followed by 55 base32 characters (A-Z, 2-7). + */ +export function isValidStellarAddress(address: string): boolean { + if (typeof address !== 'string') return false; + const trimmed = address.trim(); + return /^G[A-Z2-7]{55}$/.test(trimmed); +} + +export interface CosignerEntry { + address: string; + error: string | null; +} + +export interface CosignerSection { + enabled: boolean; + cosigners: CosignerEntry[]; + threshold: number; + thresholdError: string | null; + setEnabled: (enabled: boolean) => void; + addCosigner: (address?: string) => void; + removeCosigner: (index: number) => void; + updateCosigner: (index: number, address: string) => void; + setThreshold: (threshold: number) => void; + resetCosigners: () => void; + validateCosigners: () => boolean; + cosignerPayload: { + cosigners: string[]; + cosignerThreshold: number; + } | null; +} + +const DEFAULT_THRESHOLD = 1; + +function createEmptyCosigner(): CosignerEntry { + return { address: '', error: null }; +} + +/** + * Manages the optional co-signer approval section of the invoice creation form: + * address list state, threshold state, validation, and the create payload. + */ +export function useCosigners(): CosignerSection { + const [enabled, setEnabledState] = useState(false); + const [cosigners, setCosigners] = useState([createEmptyCosigner()]); + const [threshold, setThresholdState] = useState(DEFAULT_THRESHOLD); + const [thresholdError, setThresholdError] = useState(null); + + const setEnabled = useCallback((next: boolean) => { + setEnabledState(next); + if (!next) { + setCosigners([createEmptyCosigner()]); + setThresholdState(DEFAULT_THRESHOLD); + setThresholdError(null); + } + }, []); + + const addCosigner = useCallback((address?: string) => { + setCosigners((prev) => [ + ...prev, + { address: address ?? '', error: null }, + ]); + }, []); + + const removeCosigner = useCallback((index: number) => { + setCosigners((prev) => { + if (index < 0 || index >= prev.length) return prev; + const next = prev.filter((_, i) => i !== index); + return next.length > 0 ? next : [createEmptyCosigner()]; + }); + }, []); + + const updateCosigner = useCallback((index: number, address: string) => { + setCosigners((prev) => { + if (index < 0 || index >= prev.length) return prev; + const next = [...prev]; + const trimmed = address.trim(); + const error = + trimmed.length === 0 + ? null + : isValidStellarAddress(trimmed) + ? null + : 'Invalid Stellar address'; + next[index] = { address: trimmed, error }; + return next; + }); + }, []); + + const setThreshold = useCallback((next: number) => { + setThresholdState(next); + setThresholdError(null); + }, []); + + const resetCosigners = useCallback(() => { + setEnabledState(false); + setCosigners([createEmptyCosigner()]); + setThresholdState(DEFAULT_THRESHOLD); + setThresholdError(null); + }, []); + + const validateCosigners = useCallback((): boolean => { + if (!enabled) return true; + + const filled = cosigners.map((c) => c.address.trim()).filter((a) => a.length > 0); + + if (filled.length === 0) { + setCosigners((prev) => + prev.map((c) => ({ ...c, error: 'At least one co-signer address is required' })), + ); + return false; + } + + let addressesValid = true; + setCosigners((prev) => + prev.map((c) => { + const trimmed = c.address.trim(); + if (trimmed.length === 0) { + return { ...c, error: 'Address is required' }; + } + if (!isValidStellarAddress(trimmed)) { + addressesValid = false; + return { ...c, error: 'Invalid Stellar address' }; + } + return { ...c, error: null }; + }), + ); + + if (!addressesValid) return false; + + const unique = new Set(filled); + if (unique.size !== filled.length) { + setCosigners((prev) => + prev.map((c) => ({ ...c, error: 'Duplicate co-signer address' })), + ); + return false; + } + + if (!Number.isInteger(threshold) || threshold < 1 || threshold > filled.length) { + setThresholdError(`Threshold must be between 1 and ${filled.length}`); + return false; + } + + setThresholdError(null); + return true; + }, [enabled, cosigners, threshold]); + + const cosignerPayload = useMemo(() => { + if (!enabled) return null; + const addresses = cosigners + .map((c) => c.address.trim()) + .filter((a) => a.length > 0 && isValidStellarAddress(a)); + if (addresses.length === 0) return null; + return { + cosigners: addresses, + cosignerThreshold: threshold, + }; + }, [enabled, cosigners, threshold]); + + return { + enabled, + cosigners, + threshold, + thresholdError, + setEnabled, + addCosigner, + removeCosigner, + updateCosigner, + setThreshold, + resetCosigners, + validateCosigners, + cosignerPayload, + }; +} + /** * Composes `useInvoiceMeta` (top-level invoice fields + validation) and * `useLineItems` (item CRUD, totals, reordering) into the unified API * existing callers already depend on. See #635. + * + * Also exposes the optional co-signer section (#383) via `cosigners`, + * `cosignerThreshold`, and the `cosignerSection` helpers. */ export function useInvoiceForm() { const meta = useInvoiceMeta(); const lineItems = useLineItems(); + const cosignerSection = useCosigners(); return { ...meta, ...lineItems, + cosignerSection, + cosigners: cosignerSection.cosigners, + cosignerThreshold: cosignerSection.threshold, + cosignersEnabled: cosignerSection.enabled, + setCosignersEnabled: cosignerSection.setEnabled, + addCosigner: cosignerSection.addCosigner, + removeCosigner: cosignerSection.removeCosigner, + updateCosigner: cosignerSection.updateCosigner, + setCosignerThreshold: cosignerSection.setThreshold, + resetCosigners: cosignerSection.resetCosigners, + validateCosigners: cosignerSection.validateCosigners, + cosignerPayload: cosignerSection.cosignerPayload, }; -} +} \ No newline at end of file diff --git a/src/hooks/useInvoiceStream.ts b/src/hooks/useInvoiceStream.ts index 0d72ea82..9be616fa 100644 --- a/src/hooks/useInvoiceStream.ts +++ b/src/hooks/useInvoiceStream.ts @@ -5,11 +5,13 @@ import type { Invoice } from '@stellar-split/sdk'; import type { StellarSplitClient } from '@stellar-split/sdk'; export interface InvoiceStreamEvent { - type: 'PaymentReceived' | 'InvoiceReleased' | 'InvoiceRefunded'; + type: 'PaymentReceived' | 'InvoiceReleased' | 'InvoiceRefunded' | 'CoSignerApproved'; /** Payer address for PaymentReceived events */ payer?: string; /** Amount in stroops for PaymentReceived events */ amount?: bigint; + /** Co-signer address for CoSignerApproved events */ + coSigner?: string; /** Raw event data */ raw: unknown; } @@ -19,6 +21,10 @@ export interface UseInvoiceStreamResult { latestEvent: InvoiceStreamEvent | null; isConnected: boolean; error: string | null; + /** Increments whenever a co-signer approval event is observed. */ + coSignerApprovalCount: number; + /** Latest co-signer address that approved, if any. */ + lastApprovedCoSigner: string | null; } const POLL_INTERVAL_MS = 3_000; @@ -43,6 +49,8 @@ export function useInvoiceStream(invoiceId: string): UseInvoiceStreamResult { const [latestEvent, setLatestEvent] = useState(null); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState(null); + const [coSignerApprovalCount, setCoSignerApprovalCount] = useState(0); + const [lastApprovedCoSigner, setLastApprovedCoSigner] = useState(null); const pollRef = useRef | null>(null); const reconnectDelayRef = useRef(1_000); @@ -148,6 +156,18 @@ export function useInvoiceStream(invoiceId: string): UseInvoiceStreamResult { type: 'InvoiceRefunded', raw: event, }); + } else if ( + eventName.includes('CoSignerApproved') || + eventName.includes('CosignerApproved') + ) { + const coSigner = topics[1]?.toString() ?? ''; + setLatestEvent({ + type: 'CoSignerApproved', + coSigner, + raw: event, + }); + setCoSignerApprovalCount((count) => count + 1); + setLastApprovedCoSigner(coSigner || null); } } catch { // Skip unparseable events @@ -168,6 +188,8 @@ export function useInvoiceStream(invoiceId: string): UseInvoiceStreamResult { mountedRef.current = true; lastEventCursorRef.current = null; lastStatusRef.current = null; + setCoSignerApprovalCount(0); + setLastApprovedCoSigner(null); // Initial fetch fetchInvoice(); @@ -205,5 +227,12 @@ export function useInvoiceStream(invoiceId: string): UseInvoiceStreamResult { return () => clearTimeout(retryTimer); }, [isConnected, fetchInvoice]); - return { invoice, latestEvent, isConnected, error }; + return { + invoice, + latestEvent, + isConnected, + error, + coSignerApprovalCount, + lastApprovedCoSigner, + }; } \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index dbe96cef..55b26f73 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -77,3 +77,235 @@ export async function apiFetch( return fetch(input, init); } + +/* -------------------------------------------------------------------------- */ +/* Co-signer API helpers */ +/* -------------------------------------------------------------------------- */ + +/** Approval state of a single co-signer. */ +export type CoSignerStatus = "pending" | "approved"; + +/** A single required co-signer on an invoice. */ +export interface CoSigner { + /** Stellar address of the co-signer. */ + address: string; + /** Current approval state. */ + status: CoSignerStatus; + /** Optional signature payload submitted on approval. */ + signature?: string; + /** ISO timestamp of when the co-signer approved, if applicable. */ + approvedAt?: string; +} + +/** Shape returned by `fetchCoSigners`. */ +export interface CoSignerState { + cosigners: CoSigner[]; + cosignerThreshold: number; +} + +/** Classified error thrown by the co-signer helpers. */ +export class CoSignerApiError extends Error { + /** HTTP status code, or 0 for network/parse failures. */ + readonly status: number; + /** Machine-readable error classification. */ + readonly code: + | "network" + | "unauthorized" + | "forbidden" + | "not_found" + | "conflict" + | "rate_limited" + | "server" + | "parse" + | "unknown"; + + constructor( + message: string, + status: number, + code: CoSignerApiError["code"] + ) { + super(message); + this.name = "CoSignerApiError"; + this.status = status; + this.code = code; + } +} + +function classifyStatus(status: number): CoSignerApiError["code"] { + if (status === 401) return "unauthorized"; + if (status === 403) return "forbidden"; + if (status === 404) return "not_found"; + if (status === 409) return "conflict"; + if (status === 429) return "rate_limited"; + if (status >= 500) return "server"; + return "unknown"; +} + +async function readErrorMessage(response: Response): Promise { + try { + const body = await response.json(); + if (body && typeof body.error === "string") return body.error; + if (body && typeof body.message === "string") return body.message; + } catch { + /* ignore parse failures */ + } + return `Request failed with status ${response.status}`; +} + +function normalizeCoSigner(raw: unknown): CoSigner { + const record = (raw ?? {}) as Record; + const status = record.status === "approved" ? "approved" : "pending"; + return { + address: typeof record.address === "string" ? record.address : "", + status, + signature: + typeof record.signature === "string" ? record.signature : undefined, + approvedAt: + typeof record.approvedAt === "string" ? record.approvedAt : undefined, + }; +} + +/** + * Fetch the co-signer list and threshold for an invoice. + * + * Reads `splitMeta.cosigners` and `splitMeta.cosignerThreshold` from + * `GET /api/invoices/:id`. + */ +export async function fetchCoSigners( + invoiceId: string +): Promise { + let response: Response; + try { + response = await apiFetch(`/api/invoices/${encodeURIComponent(invoiceId)}`); + } catch (err) { + throw new CoSignerApiError( + err instanceof Error ? err.message : "Network request failed", + 0, + "network" + ); + } + + if (!response.ok) { + throw new CoSignerApiError( + await readErrorMessage(response), + response.status, + classifyStatus(response.status) + ); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new CoSignerApiError( + "Failed to parse invoice response", + response.status, + "parse" + ); + } + + const splitMeta = + body && typeof body === "object" + ? ((body as Record).splitMeta as + | Record + | undefined) + : undefined; + + const rawCosigners = Array.isArray(splitMeta?.cosigners) + ? (splitMeta!.cosigners as unknown[]) + : []; + const threshold = + typeof splitMeta?.cosignerThreshold === "number" + ? splitMeta.cosignerThreshold + : 0; + + return { + cosigners: rawCosigners.map(normalizeCoSigner), + cosignerThreshold: threshold, + }; +} + +/** + * Submit an approval for a co-signer by patching the invoice's `splitMeta`. + * + * Sends `PATCH /api/invoices/:id` with the updated co-signer entry. The + * caller is responsible for passing the full current list via `cosigners` + * when the backend requires a full replacement; otherwise only the approving + * address is sent. + */ +export async function submitCoSignerApproval( + invoiceId: string, + address: string, + signature?: string, + cosigners?: CoSigner[] +): Promise { + const approvedEntry: CoSigner = { + address, + status: "approved", + signature, + approvedAt: new Date().toISOString(), + }; + + const nextCosigners = cosigners + ? cosigners.map((c) => (c.address === address ? approvedEntry : c)) + : [approvedEntry]; + + let response: Response; + try { + response = await apiFetch( + `/api/invoices/${encodeURIComponent(invoiceId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + splitMeta: { cosigners: nextCosigners }, + }), + } + ); + } catch (err) { + throw new CoSignerApiError( + err instanceof Error ? err.message : "Network request failed", + 0, + "network" + ); + } + + if (!response.ok) { + throw new CoSignerApiError( + await readErrorMessage(response), + response.status, + classifyStatus(response.status) + ); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new CoSignerApiError( + "Failed to parse approval response", + response.status, + "parse" + ); + } + + const splitMeta = + body && typeof body === "object" + ? ((body as Record).splitMeta as + | Record + | undefined) + : undefined; + + const rawCosigners = Array.isArray(splitMeta?.cosigners) + ? (splitMeta!.cosigners as unknown[]) + : nextCosigners; + const threshold = + typeof splitMeta?.cosignerThreshold === "number" + ? splitMeta.cosignerThreshold + : 0; + + return { + cosigners: rawCosigners.map(normalizeCoSigner), + cosignerThreshold: threshold, + }; +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 39152421..a214c714 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -2,6 +2,8 @@ export type ErrorKind = | "wallet_not_connected" | "invoice_not_found" | "rpc_timeout" + | "not_a_cosigner" + | "cosigner_approval_failed" | "unknown"; export interface ClassifiedError { @@ -25,6 +27,16 @@ const PATTERNS: { kind: ErrorKind; match: RegExp; message: string }[] = [ match: /rpc timeout|timed out|timeout/i, message: "The Stellar RPC request timed out. Please try again.", }, + { + kind: "not_a_cosigner", + match: /not a cosigner|not a co-signer|not a designated cosigner|not a designated co-signer|cosigner not found|co-signer not found/i, + message: "This wallet is not a designated co-signer for this invoice.", + }, + { + kind: "cosigner_approval_failed", + match: /cosigner approval failed|co-signer approval failed|approval failed|failed to approve/i, + message: "Co-signer approval could not be submitted. Please try again.", + }, ]; export function classifyError(error: unknown): ClassifiedError { diff --git a/src/lib/freighter.ts b/src/lib/freighter.ts index 2a37614e..df704325 100644 --- a/src/lib/freighter.ts +++ b/src/lib/freighter.ts @@ -127,3 +127,50 @@ export async function signTransaction(xdr: string): Promise { // Fall back to Freighter return signWithFreighter(xdr); } + +/** Build the canonical co-signer approval message for an invoice. */ +export function buildCoSignerApprovalMessage(invoiceId: string, address: string): string { + return [ + "Stellar Split — Co-signer Approval", + `Invoice: ${invoiceId}`, + `Co-signer: ${address}`, + `Network: ${NETWORK_PASSPHRASE}`, + ].join("\n"); +} + +export interface CoSignerApprovalSignature { + /** The co-signer address that produced the signature. */ + address: string; + /** The invoice being approved. */ + invoiceId: string; + /** The exact message that was signed (for server-side verification). */ + message: string; + /** Base64-encoded signature returned by the wallet. */ + signature: string; +} + +/** + * Sign a co-signer approval for an invoice using the connected wallet. + * + * Builds a deterministic approval message and signs it via the active wallet + * adapter (WalletConnect if connected, otherwise Freighter). Throws if no + * wallet is connected so callers can prompt the user to connect first. + */ +export async function signCoSignerApproval( + invoiceId: string, + address: string, +): Promise { + if (typeof window === "undefined") throw new Error("Browser only"); + if (!invoiceId) throw new Error("Invoice ID is required"); + if (!address) throw new Error("Co-signer address is required"); + + const connected = await isWalletConnected(); + if (!connected) { + throw new Error("Wallet not connected. Please connect your Freighter wallet."); + } + + const message = buildCoSignerApprovalMessage(invoiceId, address); + const signature = await signTransaction(message); + + return { address, invoiceId, message, signature }; +}