Skip to content

Commit 389dc83

Browse files
feat: add confidential payment commitment and reveal flow (#391)
1 parent 3cfc3fe commit 389dc83

4 files changed

Lines changed: 798 additions & 0 deletions

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
2+
import { describe, it, expect, vi, beforeEach } from "vitest";
3+
import ConfidentialPaymentFlow from "@/components/ConfidentialPaymentFlow";
4+
5+
const mockInvoiceId = "inv-42";
6+
const mockPublicKey = "GABCDEF1234567890";
7+
8+
vi.mock("@stellar-split/sdk", () => ({
9+
formatAmount: (value: bigint) => (Number(value) / 10_000_000).toFixed(2),
10+
parseAmount: (value: string) =>
11+
BigInt(Math.round(Number(value) * 10_000_000)),
12+
}));
13+
14+
const mockGenerateBlindingFactor = vi.fn();
15+
const mockCreatePedersenCommitment = vi.fn();
16+
const mockSaveBlindingFactor = vi.fn();
17+
const mockLoadBlindingFactor = vi.fn();
18+
const mockHasBlindingFactor = vi.fn();
19+
const mockSaveCommittedAmount = vi.fn();
20+
const mockLoadCommittedAmount = vi.fn();
21+
const mockMarkRevealed = vi.fn();
22+
const mockIsRevealed = vi.fn();
23+
const mockSubmitCommitment = vi.fn();
24+
const mockRevealPayment = vi.fn();
25+
26+
vi.mock("@/lib/confidential", () => ({
27+
generateBlindingFactor: (...args: any[]) =>
28+
mockGenerateBlindingFactor(...args),
29+
createPedersenCommitment: (...args: any[]) =>
30+
mockCreatePedersenCommitment(...args),
31+
saveBlindingFactor: (...args: any[]) => mockSaveBlindingFactor(...args),
32+
loadBlindingFactor: (...args: any[]) => mockLoadBlindingFactor(...args),
33+
hasBlindingFactor: (...args: any[]) => mockHasBlindingFactor(...args),
34+
saveCommittedAmount: (...args: any[]) => mockSaveCommittedAmount(...args),
35+
loadCommittedAmount: (...args: any[]) => mockLoadCommittedAmount(...args),
36+
markRevealed: (...args: any[]) => mockMarkRevealed(...args),
37+
isRevealed: (...args: any[]) => mockIsRevealed(...args),
38+
submitCommitment: (...args: any[]) => mockSubmitCommitment(...args),
39+
revealPayment: (...args: any[]) => mockRevealPayment(...args),
40+
}));
41+
42+
describe("ConfidentialPaymentFlow", () => {
43+
beforeEach(() => {
44+
vi.clearAllMocks();
45+
mockIsRevealed.mockReturnValue(false);
46+
mockHasBlindingFactor.mockReturnValue(false);
47+
mockLoadCommittedAmount.mockReturnValue(null);
48+
});
49+
50+
describe("initial state", () => {
51+
it("shows the commitment form when no blinding factor exists", async () => {
52+
render(
53+
<ConfidentialPaymentFlow
54+
invoiceId={mockInvoiceId}
55+
publicKey={mockPublicKey}
56+
/>
57+
);
58+
expect(
59+
await screen.findByLabelText(/amount \(usdc\)/i)
60+
).toBeInTheDocument();
61+
expect(
62+
screen.getByRole("button", { name: /commit payment/i })
63+
).toBeInTheDocument();
64+
expect(
65+
screen.queryByRole("button", { name: /reveal payment/i })
66+
).not.toBeInTheDocument();
67+
});
68+
69+
it("shows the reveal step when a blinding factor exists", async () => {
70+
mockHasBlindingFactor.mockReturnValue(true);
71+
mockLoadCommittedAmount.mockReturnValue("10.5");
72+
73+
render(
74+
<ConfidentialPaymentFlow
75+
invoiceId={mockInvoiceId}
76+
publicKey={mockPublicKey}
77+
/>
78+
);
79+
expect(
80+
await screen.findByRole("button", { name: /reveal payment/i })
81+
).toBeInTheDocument();
82+
const input = screen.getByLabelText(/amount \(usdc\)/i) as HTMLInputElement;
83+
expect(input.value).toBe("10.5");
84+
expect(input).toBeDisabled();
85+
});
86+
87+
it("shows revealed state when payment was already revealed", async () => {
88+
mockIsRevealed.mockReturnValue(true);
89+
90+
render(
91+
<ConfidentialPaymentFlow
92+
invoiceId={mockInvoiceId}
93+
publicKey={mockPublicKey}
94+
/>
95+
);
96+
expect(
97+
await screen.findByText(/payment revealed successfully/i)
98+
).toBeInTheDocument();
99+
});
100+
101+
it("shows missing blinding factor warning when reveal attempted without stored factor", async () => {
102+
mockHasBlindingFactor.mockReturnValue(true);
103+
mockLoadBlindingFactor.mockReturnValue(null);
104+
105+
render(
106+
<ConfidentialPaymentFlow
107+
invoiceId={mockInvoiceId}
108+
publicKey={mockPublicKey}
109+
/>
110+
);
111+
const revealButton = await screen.findByRole("button", {
112+
name: /reveal payment/i,
113+
});
114+
fireEvent.click(revealButton);
115+
expect(
116+
await screen.findByText(/recovery secret not found/i)
117+
).toBeInTheDocument();
118+
});
119+
});
120+
121+
describe("commitment flow", () => {
122+
it("commits successfully and transitions to committed state", async () => {
123+
mockGenerateBlindingFactor.mockReturnValue("abc123");
124+
mockCreatePedersenCommitment.mockResolvedValue("commitment_hash");
125+
mockSubmitCommitment.mockResolvedValue({ txHash: "tx_hash_123" });
126+
127+
render(
128+
<ConfidentialPaymentFlow
129+
invoiceId={mockInvoiceId}
130+
publicKey={mockPublicKey}
131+
/>
132+
);
133+
const input = await screen.findByLabelText(/amount \(usdc\)/i);
134+
fireEvent.change(input, { target: { value: "50" } });
135+
fireEvent.click(screen.getByRole("button", { name: /commit payment/i }));
136+
137+
expect(
138+
await screen.findByText(/commitment submitted successfully/i)
139+
).toBeInTheDocument();
140+
expect(mockGenerateBlindingFactor).toHaveBeenCalledTimes(1);
141+
expect(mockCreatePedersenCommitment).toHaveBeenCalledWith(
142+
500_000_000n,
143+
"abc123"
144+
);
145+
expect(mockSubmitCommitment).toHaveBeenCalledWith({
146+
payer: mockPublicKey,
147+
invoiceId: mockInvoiceId,
148+
commitment: "commitment_hash",
149+
});
150+
expect(mockSaveBlindingFactor).toHaveBeenCalledWith(
151+
mockInvoiceId,
152+
mockPublicKey,
153+
"abc123"
154+
);
155+
expect(mockSaveCommittedAmount).toHaveBeenCalledWith(
156+
mockInvoiceId,
157+
mockPublicKey,
158+
"50"
159+
);
160+
});
161+
162+
it("shows loading state while committing", async () => {
163+
let resolveCommit: (value: any) => void;
164+
mockSubmitCommitment.mockReturnValue(
165+
new Promise((resolve) => {
166+
resolveCommit = resolve;
167+
})
168+
);
169+
170+
render(
171+
<ConfidentialPaymentFlow
172+
invoiceId={mockInvoiceId}
173+
publicKey={mockPublicKey}
174+
/>
175+
);
176+
const input = await screen.findByLabelText(/amount \(usdc\)/i);
177+
fireEvent.change(input, { target: { value: "25" } });
178+
fireEvent.click(screen.getByRole("button", { name: /commit payment/i }));
179+
180+
expect(
181+
screen.getByRole("button", { name: /submitting commitment/i })
182+
).toBeInTheDocument();
183+
184+
resolveCommit!({ txHash: "tx" });
185+
await waitFor(() =>
186+
expect(
187+
screen.queryByRole("button", { name: /submitting commitment/i })
188+
).not.toBeInTheDocument()
189+
);
190+
});
191+
192+
it("shows error on commitment failure and allows retry", async () => {
193+
mockSubmitCommitment.mockRejectedValue(new Error("Network error"));
194+
195+
render(
196+
<ConfidentialPaymentFlow
197+
invoiceId={mockInvoiceId}
198+
publicKey={mockPublicKey}
199+
/>
200+
);
201+
const input = await screen.findByLabelText(/amount \(usdc\)/i);
202+
fireEvent.change(input, { target: { value: "30" } });
203+
fireEvent.click(screen.getByRole("button", { name: /commit payment/i }));
204+
205+
expect(await screen.findByText(/network error/i)).toBeInTheDocument();
206+
expect(
207+
screen.getByRole("button", { name: /commit payment/i })
208+
).toBeInTheDocument();
209+
210+
mockSubmitCommitment.mockResolvedValue({ txHash: "tx_456" });
211+
fireEvent.click(screen.getByRole("button", { name: /commit payment/i }));
212+
expect(
213+
await screen.findByText(/commitment submitted successfully/i)
214+
).toBeInTheDocument();
215+
});
216+
});
217+
218+
describe("reveal flow", () => {
219+
it("reveals successfully and shows completed state", async () => {
220+
mockHasBlindingFactor.mockReturnValue(true);
221+
mockLoadCommittedAmount.mockReturnValue("75");
222+
mockLoadBlindingFactor.mockReturnValue("blinding_123");
223+
mockRevealPayment.mockResolvedValue({ txHash: "tx_reveal_123" });
224+
225+
render(
226+
<ConfidentialPaymentFlow
227+
invoiceId={mockInvoiceId}
228+
publicKey={mockPublicKey}
229+
/>
230+
);
231+
const revealButton = await screen.findByRole("button", {
232+
name: /reveal payment/i,
233+
});
234+
fireEvent.click(revealButton);
235+
236+
expect(
237+
await screen.findByText(/payment revealed successfully/i)
238+
).toBeInTheDocument();
239+
expect(mockLoadBlindingFactor).toHaveBeenCalledWith(
240+
mockInvoiceId,
241+
mockPublicKey
242+
);
243+
expect(mockRevealPayment).toHaveBeenCalledWith({
244+
payer: mockPublicKey,
245+
invoiceId: mockInvoiceId,
246+
amount: 750_000_000n,
247+
blindingFactor: "blinding_123",
248+
});
249+
expect(mockMarkRevealed).toHaveBeenCalledWith(
250+
mockInvoiceId,
251+
mockPublicKey
252+
);
253+
});
254+
255+
it("shows error on reveal failure", async () => {
256+
mockHasBlindingFactor.mockReturnValue(true);
257+
mockLoadCommittedAmount.mockReturnValue("10");
258+
mockLoadBlindingFactor.mockReturnValue("blinding_456");
259+
mockRevealPayment.mockRejectedValue(new Error("Reveal failed"));
260+
261+
render(
262+
<ConfidentialPaymentFlow
263+
invoiceId={mockInvoiceId}
264+
publicKey={mockPublicKey}
265+
/>
266+
);
267+
const revealButton = await screen.findByRole("button", {
268+
name: /reveal payment/i,
269+
});
270+
fireEvent.click(revealButton);
271+
272+
expect(await screen.findByText(/reveal failed/i)).toBeInTheDocument();
273+
expect(
274+
screen.getByRole("button", { name: /reveal payment/i })
275+
).toBeInTheDocument();
276+
});
277+
});
278+
279+
describe("user copy", () => {
280+
it("shows explanatory text about confidential payments", async () => {
281+
render(
282+
<ConfidentialPaymentFlow
283+
invoiceId={mockInvoiceId}
284+
publicKey={mockPublicKey}
285+
/>
286+
);
287+
expect(
288+
await screen.findByText(/cryptographic commitment is stored/i)
289+
).toBeInTheDocument();
290+
expect(
291+
screen.getByText(/recovery secret.*is stored in your browser/i)
292+
).toBeInTheDocument();
293+
expect(
294+
screen.getByText(/two steps are needed/i)
295+
).toBeInTheDocument();
296+
});
297+
});
298+
});

src/app/invoice/[id]/page.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import InstallmentPanel from "@/components/InstallmentPanel";
3737
import CoCreatorPanel from "@/components/CoCreatorPanel";
3838
import PaymentChannelPanel from "@/components/PaymentChannelPanel";
3939
import DisputeTimeline from "@/components/DisputeTimeline";
40+
import ConfidentialPaymentFlow from "@/components/ConfidentialPaymentFlow";
4041
import AuditLogTable from "@/components/AuditLogTable";
4142
import VersionHistory from "@/components/VersionHistory";
4243
import CommentSection from "@/components/CommentSection";
@@ -114,6 +115,7 @@ export default function InvoiceDetailPage({ params }: Props) {
114115
const [showTransferModal, setShowTransferModal] = useState(false);
115116
const [transferError, setTransferError] = useState<string | null>(null);
116117
const [locale, setLocale] = useState<Locale>("en");
118+
const [showConfidentialFlow, setShowConfidentialFlow] = useState(false);
117119

118120
useEffect(() => {
119121
// TODO: implement notification subscription
@@ -353,6 +355,16 @@ export default function InvoiceDetailPage({ params }: Props) {
353355
>
354356
Share via QR
355357
</button>
358+
{(invoice as any).confidential && (
359+
<button
360+
type="button"
361+
onClick={() => setShowConfidentialFlow(true)}
362+
className="px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold text-white transition-colors"
363+
aria-label="Pay confidentially"
364+
>
365+
Pay Confidentially
366+
</button>
367+
)}
356368
<select
357369
value={locale}
358370
onChange={(e) => setLocale(e.target.value as Locale)}
@@ -552,6 +564,13 @@ export default function InvoiceDetailPage({ params }: Props) {
552564
</section>
553565
)}
554566

567+
{showConfidentialFlow && (invoice as any).confidential && publicKey && (
568+
<ConfidentialPaymentFlow
569+
invoiceId={id}
570+
publicKey={publicKey}
571+
/>
572+
)}
573+
555574
{showPayModal && invoice && publicKey && (
556575
<PayModal
557576
invoice={invoice}

0 commit comments

Comments
 (0)