diff --git a/frontend/src/components/programme/ProgrammeHeader.css b/frontend/src/components/programme/ProgrammeHeader.css
index 9f25cd4..85dc4e0 100644
--- a/frontend/src/components/programme/ProgrammeHeader.css
+++ b/frontend/src/components/programme/ProgrammeHeader.css
@@ -57,6 +57,16 @@
color: var(--text);
}
+/* The creation date sits beside the ledger. It may wrap independently on a
+ narrow screen, so the ledger number stays readable at 360px. */
+.programme-header__created {
+ margin-left: var(--space-1);
+}
+
+.programme-header__created time {
+ font-variant-numeric: tabular-nums;
+}
+
.programme-header__read {
display: inline-flex;
align-items: center;
diff --git a/frontend/src/components/programme/ProgrammeHeader.test.tsx b/frontend/src/components/programme/ProgrammeHeader.test.tsx
index cd75ff8..37bd27b 100644
--- a/frontend/src/components/programme/ProgrammeHeader.test.tsx
+++ b/frontend/src/components/programme/ProgrammeHeader.test.tsx
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
+import { formatDate } from '../../lib/format';
import { ProgrammeHeader } from './ProgrammeHeader';
const baseProps = {
@@ -45,6 +46,45 @@ describe('ProgrammeHeader', () => {
expect(within(badges).getByText('Sample data')).toBeTruthy();
});
+ it('shows an exact creation date beside the ledger when the RPC retains it', () => {
+ const seconds = Math.floor(Date.parse('2026-03-12T10:00:00Z') / 1000);
+ renderHeader({
+ ...baseProps,
+ phase: 'Open',
+ createdAt: { seconds, estimated: false },
+ });
+
+ const date = screen.getByText(formatDate(seconds));
+ const time = date.closest('time');
+ expect(time?.getAttribute('datetime')).toBe(new Date(seconds * 1000).toISOString());
+ expect(time?.getAttribute('title')).toMatch(/read from the ledger/i);
+ expect(time?.parentElement?.textContent).not.toContain('about');
+
+ // The ledger number stays visible alongside the date.
+ expect(screen.getByText(/4,811,204/)).toBeTruthy();
+ });
+
+ it('marks an estimated creation date as "about" beside the ledger', () => {
+ const seconds = Math.floor(Date.parse('2026-03-12T10:00:00Z') / 1000);
+ renderHeader({
+ ...baseProps,
+ phase: 'Open',
+ createdAt: { seconds, estimated: true },
+ });
+
+ const time = screen.getByText(formatDate(seconds)).closest('time');
+ expect(time?.parentElement?.textContent).toContain('about');
+ expect(time?.getAttribute('title')).toMatch(/estimated from the latest ledger/i);
+ expect(screen.getByText(/4,811,204/)).toBeTruthy();
+ });
+
+ it('shows only the ledger when no creation date can be resolved', () => {
+ renderHeader({ ...baseProps, phase: 'Open', createdAt: null });
+
+ expect(screen.getByText(/4,811,204/)).toBeTruthy();
+ expect(document.querySelector('time')).toBeNull();
+ });
+
it('returns to the directory the reader came from, filters included', () => {
renderHeader({ ...baseProps, phase: 'Open', backTo: '/directory?q=flood&phase=Open' });
diff --git a/frontend/src/components/programme/ProgrammeHeader.tsx b/frontend/src/components/programme/ProgrammeHeader.tsx
index 1781155..2336dc3 100644
--- a/frontend/src/components/programme/ProgrammeHeader.tsx
+++ b/frontend/src/components/programme/ProgrammeHeader.tsx
@@ -1,4 +1,6 @@
import { Link } from 'react-router-dom';
+import { formatDate } from '../../lib/format';
+import type { LedgerTime } from '../../lib/ledgerTime';
import { AddressChip, ModePill } from '../ui';
import './phasePill.css';
import './ProgrammeHeader.css';
@@ -42,6 +44,7 @@ export function ProgrammeHeader({
name,
creator,
createdLedger,
+ createdAt = null,
phase,
phaseError = false,
verified,
@@ -53,6 +56,12 @@ export function ProgrammeHeader({
name: string;
creator: string | null;
createdLedger: number | null;
+ /**
+ * When the creation ledger closed. `estimated` is true when the RPC no
+ * longer retained the ledger and the date was counted back from the latest
+ * one, so the header can say "about".
+ */
+ createdAt?: LedgerTime | null;
/** The on-chain phase, or null while it has not been read. */
phase: string | null;
phaseError?: boolean;
@@ -94,6 +103,24 @@ export function ProgrammeHeader({
Ledger {createdLedger?.toLocaleString() ?? '—'}
+ {createdAt && (
+
+ {createdAt.estimated ? 'about ' : ''}
+
+ {createdAt.estimated && (
+ estimated from the latest ledger
+ )}
+
+ )}
diff --git a/frontend/src/components/programme/ProgrammePanels.test.tsx b/frontend/src/components/programme/ProgrammePanels.test.tsx
index c7b3436..b5b868b 100644
--- a/frontend/src/components/programme/ProgrammePanels.test.tsx
+++ b/frontend/src/components/programme/ProgrammePanels.test.tsx
@@ -316,6 +316,34 @@ describe('Issue #267 - TermsTab component', () => {
);
expect(screen.getByText('16 votes (max 16)')).toBeDefined();
});
+
+ it('shows the programme minimum award when one is set', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Minimum award')).toBeDefined();
+ expect(screen.getByText('2,000.00 XLM')).toBeDefined();
+ expect(screen.getByText(/BelowMinimumAward, error 38/)).toBeDefined();
+ expect(screen.getByText(/median of reviewer votes/i)).toBeDefined();
+ });
+
+ it('shows no minimum row when the programme has no minimum', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByText('Minimum award')).toBeNull();
+ expect(screen.queryByText(/BelowMinimumAward/)).toBeNull();
+ });
});
describe('ProgrammeTabs wrapper component', () => {
@@ -327,6 +355,7 @@ describe('ProgrammeTabs wrapper component', () => {
phase="Settled"
quorum={3}
asset="USDC"
+ minimumAward={1_000_0000000n}
isSample
/>
,
@@ -347,5 +376,6 @@ describe('ProgrammeTabs wrapper component', () => {
fireEvent.click(termsBtn);
expect(termsBtn.getAttribute('aria-selected')).toBe('true');
expect(screen.getByText('Chosen per award')).toBeDefined();
+ expect(screen.getByText('1,000.00 USDC')).toBeDefined();
});
});
diff --git a/frontend/src/components/programme/ProgrammeTabs.tsx b/frontend/src/components/programme/ProgrammeTabs.tsx
index 4b100f1..d0b7144 100644
--- a/frontend/src/components/programme/ProgrammeTabs.tsx
+++ b/frontend/src/components/programme/ProgrammeTabs.tsx
@@ -11,6 +11,8 @@ export interface ProgrammeTabsProps {
programmeId: string;
phase?: string;
quorum?: number;
+ /** The programme's minimum award in stroops; 0n means none. */
+ minimumAward?: bigint;
isSample?: boolean;
asset?: string;
verifiers?: VerifierFixture[];
@@ -21,6 +23,7 @@ export const ProgrammeTabs: FC = ({
programmeId,
phase = 'Open',
quorum = 1,
+ minimumAward = 0n,
isSample = false,
asset = 'USDC',
verifiers,
@@ -123,6 +126,7 @@ export const ProgrammeTabs: FC = ({
programmeId={programmeId}
quorum={quorum}
asset={asset}
+ minimumAward={minimumAward}
/>
)}
diff --git a/frontend/src/components/programme/TermsTab.tsx b/frontend/src/components/programme/TermsTab.tsx
index dd4b2cb..34c78cf 100644
--- a/frontend/src/components/programme/TermsTab.tsx
+++ b/frontend/src/components/programme/TermsTab.tsx
@@ -1,5 +1,6 @@
import type { FC } from 'react';
import { Link } from 'react-router-dom';
+import { formatAmount } from '../../lib/amount';
import './TermsTab.css';
/** The four payment modes, as `Mode` documents them in the program contract. */
@@ -26,17 +27,33 @@ export interface TermsTabProps {
programmeId: string;
quorum?: number;
asset?: string;
+ /**
+ * The programme's minimum award in stroops. `0n` means no minimum, so the
+ * row is omitted rather than pretending zero is a floor.
+ */
+ minimumAward?: bigint;
}
export const TermsTab: FC = ({
programmeId,
quorum = 1,
asset = 'USDC',
+ minimumAward = 0n,
}) => {
const cappedQuorum = Math.min(Math.max(1, quorum), 16);
- const terms = [
+ const terms: Array<{ k: string; v: string; explanation?: string }> = [
{ k: 'Asset', v: asset },
+ ...(minimumAward > 0n
+ ? [
+ {
+ k: 'Minimum award',
+ v: formatAmount(minimumAward, { asset }),
+ explanation:
+ "The contract's finalize refuses any award below this amount (BelowMinimumAward, error 38). An award is the median of reviewer votes, so if the median lands below the minimum the application cannot be finalized.",
+ },
+ ]
+ : []),
{
k: 'Payment mode',
v: 'Chosen per award',
diff --git a/frontend/src/components/recipient/ApplyForm.css b/frontend/src/components/recipient/ApplyForm.css
index 5425f22..cf643e3 100644
--- a/frontend/src/components/recipient/ApplyForm.css
+++ b/frontend/src/components/recipient/ApplyForm.css
@@ -167,6 +167,31 @@
min-height: 18px;
}
+/* The programme's floor, stated before an amount is typed so it is not a
+ surprise only after the request comes in low. */
+.apply-form__minimum {
+ font-size: var(--text-caption);
+ line-height: var(--leading-body);
+ color: var(--text-muted);
+}
+
+.apply-form__minimum strong {
+ color: var(--text);
+ font-weight: var(--weight-semibold);
+}
+
+/* A warning, not an error: submitting stays allowed because the median of
+ reviewer votes may still land above the programme's minimum. */
+.apply-form__warning {
+ font-size: var(--text-caption);
+ line-height: var(--leading-body);
+ color: var(--warning-strong);
+}
+
+.apply-form__warning strong {
+ font-weight: var(--weight-bold);
+}
+
.apply-form__submit-btn {
align-self: flex-start;
cursor: pointer;
diff --git a/frontend/src/components/recipient/ApplyForm.minimumAward.test.tsx b/frontend/src/components/recipient/ApplyForm.minimumAward.test.tsx
new file mode 100644
index 0000000..2527bc9
--- /dev/null
+++ b/frontend/src/components/recipient/ApplyForm.minimumAward.test.tsx
@@ -0,0 +1,156 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { ApplyForm } from './ApplyForm';
+
+/**
+ * A programme may set a minimum award, and `finalize` refuses anything below
+ * it. The apply form must say so before submission — but must not block it,
+ * because the award is the median of reviewer votes and those votes may still
+ * land above the minimum.
+ */
+
+const mocks = vi.hoisted(() => ({
+ minimumAward: 0n,
+ getConfig: vi.fn(),
+ programmeId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+}));
+
+vi.mock('../../context/useAnnouncer', () => ({
+ useAnnouncer: () => vi.fn(),
+}));
+
+vi.mock('../../context/useWallet', () => ({
+ useWallet: () => ({
+ address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ status: 'connected',
+ network: 'TESTNET',
+ networkError: null,
+ expectedNetwork: 'Testnet',
+ connect: vi.fn(),
+ signTransaction: vi.fn(),
+ }),
+}));
+
+vi.mock('../../context/useSoroban', () => ({
+ useSoroban: () => ({
+ programmeAt: () => ({ get_config: mocks.getConfig }),
+ }),
+}));
+
+vi.mock('../../hooks/useOpenProgrammes', () => ({
+ useOpenProgrammes: () => ({
+ real: [
+ {
+ id: mocks.programmeId,
+ name: 'Live Grant Round',
+ creator: null,
+ createdLedger: 4_811_204,
+ sample: false,
+ chain: {
+ phase: 'Open',
+ contributed: '0',
+ fee: '0',
+ awarded: '0',
+ released: '0',
+ refundable: '0',
+ quorum: 1,
+ },
+ },
+ ],
+ samples: [],
+ loading: false,
+ fetching: false,
+ error: null,
+ refetch: vi.fn(),
+ }),
+}));
+
+function renderForm() {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ mocks.minimumAward = 0n;
+ mocks.getConfig.mockReset();
+ mocks.getConfig.mockImplementation(async () => ({
+ result: {
+ unwrap: () => ({ minimum_award: mocks.minimumAward }),
+ },
+ }));
+});
+
+describe('ApplyForm — programme minimum award', () => {
+ it('shows the minimum before an amount is entered', async () => {
+ mocks.minimumAward = 2_000_0000000n; // 2,000 USDC
+ renderForm();
+
+ expect(await screen.findByText(/minimum award:/i)).toBeTruthy();
+ expect(screen.getByText('2,000.00 USDC')).toBeTruthy();
+ });
+
+ it('shows no minimum row or warning when the programme has no minimum', async () => {
+ renderForm();
+
+ await screen.findByRole('radio', { name: 'Live Grant Round' });
+ fireEvent.change(screen.getByPlaceholderText('0'), {
+ target: { value: '100' },
+ });
+
+ await waitFor(() => expect(mocks.getConfig).toHaveBeenCalled());
+ expect(screen.queryByText(/minimum award:/i)).toBeNull();
+ expect(screen.queryByText(/below the programme.s minimum award/i)).toBeNull();
+ });
+
+ it('warns when the requested amount is below the minimum, without blocking submission', async () => {
+ mocks.minimumAward = 2_000_0000000n; // 2,000 USDC
+ renderForm();
+
+ await screen.findByRole('radio', { name: 'Live Grant Round' });
+ fireEvent.change(screen.getByPlaceholderText('0'), {
+ target: { value: '1500' },
+ });
+
+ const warning = await screen.findByText(/below the programme.s minimum award/i);
+ expect(warning.textContent).toContain('2,000.00 USDC');
+ expect(warning.textContent).toMatch(/median of reviewer votes/i);
+ expect(warning.textContent).toMatch(/cannot be finalized/i);
+ expect(warning.textContent).toMatch(/can still submit/i);
+
+ // The warning is advice, not a validation rule: with notes present the
+ // submit button stays enabled.
+ fireEvent.change(screen.getByRole('textbox', { name: /application notes/i }), {
+ target: { value: 'Exam fees for term 3.' },
+ });
+ expect(
+ (screen.getByRole('button', { name: /submit application/i }) as HTMLButtonElement)
+ .disabled,
+ ).toBe(false);
+ });
+
+ it('stops warning once the requested amount is at or above the minimum', async () => {
+ mocks.minimumAward = 2_000_0000000n;
+ renderForm();
+
+ await screen.findByRole('radio', { name: 'Live Grant Round' });
+ const amount = screen.getByPlaceholderText('0');
+
+ fireEvent.change(amount, { target: { value: '1500' } });
+ expect(
+ await screen.findByText(/below the programme.s minimum award/i),
+ ).toBeTruthy();
+
+ fireEvent.change(amount, { target: { value: '2500' } });
+ await waitFor(() =>
+ expect(screen.queryByText(/below the programme.s minimum award/i)).toBeNull(),
+ );
+
+ // Exactly the minimum is allowed.
+ fireEvent.change(amount, { target: { value: '2000' } });
+ expect(screen.queryByText(/below the programme.s minimum award/i)).toBeNull();
+ });
+});
diff --git a/frontend/src/components/recipient/ApplyForm.tsx b/frontend/src/components/recipient/ApplyForm.tsx
index 64afb84..bb2d728 100644
--- a/frontend/src/components/recipient/ApplyForm.tsx
+++ b/frontend/src/components/recipient/ApplyForm.tsx
@@ -2,6 +2,7 @@ import { useState, useId } from 'react';
import { Link } from 'react-router-dom';
import { Buffer } from 'buffer';
import { useOpenProgrammes } from '../../hooks/useOpenProgrammes';
+import { useContractRead } from '../../hooks/useContractRead';
import { useTransaction } from '../../hooks/useTransaction';
import { useWallet } from '../../context/useWallet';
import { useSoroban } from '../../context/useSoroban';
@@ -60,6 +61,7 @@ export function ApplyForm({
const amountFieldId = useId();
const notesFieldId = useId();
const helpId = useId();
+ const minimumId = useId();
const { address } = useWallet();
const { programmeAt } = useSoroban();
@@ -86,6 +88,35 @@ export function ApplyForm({
? selectedProgrammeId
: allOpen[0]?.id ?? '';
+ // A programme can set a minimum award; finalize refuses anything below it.
+ // Reading it here lets the form warn before the application is submitted,
+ // without turning the minimum into a client-side block: reviewer votes may
+ // still produce a median above it.
+ const selectionIsSample = isSampleProgrammeId(effectiveSelection);
+ const minimumRead = useContractRead<{
+ programmeId: string;
+ minimumAward: bigint;
+ }>(
+ async () => {
+ const response = await programmeAt(effectiveSelection).get_config();
+ const config = response.result.unwrap();
+ return {
+ result: {
+ programmeId: effectiveSelection,
+ minimumAward: config.minimum_award ?? 0n,
+ },
+ };
+ },
+ [programmeAt, effectiveSelection],
+ { enabled: Boolean(effectiveSelection) && !selectionIsSample },
+ );
+ // A read for a previous programme must not colour the current one, and a
+ // sample programme has no on-chain minimum at all.
+ const minimumAward =
+ !selectionIsSample && minimumRead.data?.programmeId === effectiveSelection
+ ? minimumRead.data.minimumAward
+ : 0n;
+
// Amount validation using the project's existing parseAmount util.
const amountResult = amountInput.trim()
? (() => {
@@ -99,6 +130,10 @@ export function ApplyForm({
: null;
const amountError = amountResult && !amountResult.ok ? amountResult.error : '';
+ const belowMinimum =
+ amountResult?.ok === true &&
+ minimumAward > 0n &&
+ amountResult.value < minimumAward;
const isValid =
amountResult?.ok === true &&
Boolean(effectiveSelection) &&
@@ -286,7 +321,7 @@ export function ApplyForm({
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
placeholder="0"
- aria-describedby={helpId}
+ aria-describedby={minimumAward > 0n ? `${helpId} ${minimumId}` : helpId}
className="apply-form__input"
/>
USDC
@@ -295,6 +330,24 @@ export function ApplyForm({
Ask for what you actually need. Reviewers can approve up to this
amount, not more. Your award is the middle of their votes.
+ {minimumAward > 0n && (
+
+ Minimum award:{' '}
+
+ {formatAmount(minimumAward, { asset: 'USDC' })}
+
+ .
+
+ )}
+ {belowMinimum && (
+
+ This is below the programme’s minimum award of{' '}
+ {formatAmount(minimumAward, { asset: 'USDC' })}.
+ Your award is the median of reviewer votes, so a median below
+ the minimum cannot be finalized. You can still submit — votes
+ may land above it.
+
+ )}
{amountError}
diff --git a/frontend/src/components/recipient/RecipientFlows.test.tsx b/frontend/src/components/recipient/RecipientFlows.test.tsx
index 5eeacac..b640304 100644
--- a/frontend/src/components/recipient/RecipientFlows.test.tsx
+++ b/frontend/src/components/recipient/RecipientFlows.test.tsx
@@ -116,6 +116,10 @@ function makeProgramme(opts: {
})),
apply: vi.fn(async () => ({ signAndSend: applySignAndSend })),
_applySignAndSend: applySignAndSend,
+ // ApplyForm reads the programme minimum before submitting. No minimum here.
+ get_config: vi.fn(async () => ({
+ result: { unwrap: () => ({ minimum_award: 0n }) },
+ })),
release: vi.fn(async () => ({ signAndSend: releaseSignAndSend })),
get_award: vi.fn(async () => ({
result: { unwrap: () => freshAward },
diff --git a/frontend/src/components/ui/AddressChip.test.tsx b/frontend/src/components/ui/AddressChip.test.tsx
new file mode 100644
index 0000000..9a41e4a
--- /dev/null
+++ b/frontend/src/components/ui/AddressChip.test.tsx
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { AddressChip } from './AddressChip';
+import { truncateAddress } from '../../lib/format';
+
+/**
+ * Addresses are how people check who they are paying. The chip must show a
+ * shortened form that is still distinguishable, hand assistive technology the
+ * full value, and link to the correct explorer page — account or contract,
+ * testnet or mainnet. A bug here shows someone the wrong address or sends them
+ * to the wrong account page.
+ */
+
+const ACCOUNT = 'GAH3D4RM45ETE4W7VDRCWZBPRPT63CJXAGXFYVBC2FGANBZTS4OTKXCA';
+const CONTRACT = 'CD236SGR4CHW3N5WA5REW7CDLCS4ZLDEX6JVEAIHZK7NSN4W7WD7YDAL';
+
+describe('AddressChip', () => {
+ it('shows the shortened form and exposes the full address to assistive tech', () => {
+ render();
+
+ const shortened = screen.getByText(truncateAddress(ACCOUNT));
+ expect(shortened.getAttribute('aria-hidden')).toBe('true');
+
+ const full = screen.getByText(ACCOUNT);
+ expect(full.className).toContain('visually-hidden');
+
+ // The title remains the full value for a mouse hover, too.
+ expect(screen.getByTitle(ACCOUNT)).toBeTruthy();
+ });
+
+ it('honours custom lead and tail lengths', () => {
+ render();
+
+ expect(screen.getByText(truncateAddress(CONTRACT, 4, 3))).toBeTruthy();
+ expect(truncateAddress(CONTRACT, 4, 3)).not.toBe(truncateAddress(CONTRACT));
+ });
+
+ it('leaves a value shorter than the truncation untouched', () => {
+ render();
+
+ // Both the visible code and the assistive-tech copy carry the same short
+ // value, so exactly two nodes hold it.
+ expect(screen.getAllByText('GABC')).toHaveLength(2);
+ });
+
+ it('labels the copy button with the full address and a sensible default', () => {
+ const { rerender } = render();
+ expect(screen.getByRole('button', { name: 'Copy address' })).toBeTruthy();
+
+ rerender();
+ expect(screen.getByRole('button', { name: 'Copy treasury address' })).toBeTruthy();
+ });
+
+ it('renders the registry verdict only when one is supplied', () => {
+ const { rerender } = render();
+ expect(screen.queryByText('Registry-deployed')).toBeNull();
+ expect(screen.queryByText('Not registry-deployed')).toBeNull();
+
+ rerender();
+ expect(screen.getByText('Registry-deployed')).toBeTruthy();
+
+ rerender();
+ expect(screen.getByText('Not registry-deployed')).toBeTruthy();
+ });
+
+ it('links an account to its testnet page', () => {
+ render();
+
+ const link = screen.getByRole('link', { name: 'View account on Stellar Expert' });
+ expect(link.getAttribute('href')).toBe(
+ `https://stellar.expert/explorer/testnet/account/${ACCOUNT}`,
+ );
+ expect(link.getAttribute('target')).toBe('_blank');
+ expect(link.getAttribute('rel')).toBe('noreferrer');
+ });
+
+ it('links a contract to its testnet page', () => {
+ render();
+
+ expect(
+ screen.getByRole('link', { name: 'View contract on Stellar Expert' }).getAttribute('href'),
+ ).toBe(`https://stellar.expert/explorer/testnet/contract/${CONTRACT}`);
+ });
+
+ it('links a contract to its mainnet page', () => {
+ render();
+
+ const link = screen.getByRole('link', { name: 'View contract on Stellar Expert' });
+ expect(link.getAttribute('href')).toBe(
+ `https://stellar.expert/explorer/public/contract/${CONTRACT}`,
+ );
+ });
+
+ it('treats a network string without "testnet" as mainnet', () => {
+ render();
+
+ expect(
+ screen.getByRole('link', { name: 'View account on Stellar Expert' }).getAttribute('href'),
+ ).toContain('/explorer/public/account/');
+ });
+
+ it('renders no explorer link unless asked', () => {
+ render();
+
+ expect(screen.queryByRole('link')).toBeNull();
+ });
+});
diff --git a/frontend/src/components/ui/AddressChip.tsx b/frontend/src/components/ui/AddressChip.tsx
index 30eeb9e..3657861 100644
--- a/frontend/src/components/ui/AddressChip.tsx
+++ b/frontend/src/components/ui/AddressChip.tsx
@@ -46,7 +46,13 @@ export function AddressChip({
return (
- {display}
+ {/* The shortened form is what sighted users compare; assistive tech gets
+ the full value, because a truncated address is not enough to check
+ who you are paying. */}
+
+ {display}
+
+ {address}
{verification && (
{verified ? : }
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index f1e3669..bd8525f 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -17,4 +17,5 @@ export { useRecipientApplications } from './useRecipientApplications';
export { useRecipientAwards } from './useRecipientAwards';
export { useStanding } from './useStanding';
export { useRpcHealth } from './useRpcHealth';
+export { useLedgerTime } from './useLedgerTime';
diff --git a/frontend/src/hooks/useLedgerTime.test.ts b/frontend/src/hooks/useLedgerTime.test.ts
new file mode 100644
index 0000000..bc1f1dd
--- /dev/null
+++ b/frontend/src/hooks/useLedgerTime.test.ts
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { lookupLedgerTime } from '../lib/ledgerTime';
+import { useLedgerTime } from './useLedgerTime';
+
+/**
+ * The hook is the one place a screen reaches the RPC for a ledger close time.
+ * It must stay idle without a ledger (samples and unindexed programmes) and
+ * pass the ledger through to the resolver otherwise.
+ */
+
+vi.mock('../lib/ledgerTime', async (importOriginal) => ({
+ ...(await importOriginal()),
+ lookupLedgerTime: vi.fn(),
+}));
+
+const resolveLedgerTime = vi.mocked(lookupLedgerTime);
+const RPC_URL = 'https://soroban-testnet.stellar.org';
+
+beforeEach(() => {
+ resolveLedgerTime.mockReset();
+});
+
+describe('useLedgerTime', () => {
+ it('does nothing when there is no ledger to resolve', () => {
+ const { result } = renderHook(() => useLedgerTime(null, RPC_URL));
+
+ expect(result.current.data).toBeNull();
+ expect(result.current.loading).toBe(false);
+ expect(resolveLedgerTime).not.toHaveBeenCalled();
+ });
+
+ it('resolves an exact date for a ledger', async () => {
+ resolveLedgerTime.mockResolvedValue({ seconds: 1_772_712_000, estimated: false });
+
+ const { result } = renderHook(() => useLedgerTime(4_811_204, RPC_URL));
+
+ await waitFor(() =>
+ expect(result.current.data).toEqual({
+ seconds: 1_772_712_000,
+ estimated: false,
+ }),
+ );
+ expect(resolveLedgerTime).toHaveBeenCalledWith(expect.anything(), 4_811_204);
+ });
+
+ it('resolves the estimate for a different ledger', async () => {
+ resolveLedgerTime.mockResolvedValueOnce({
+ seconds: 1_772_712_000,
+ estimated: false,
+ });
+ const { result, rerender } = renderHook(
+ ({ ledger }: { ledger: number }) => useLedgerTime(ledger, RPC_URL),
+ { initialProps: { ledger: 4_811_204 } },
+ );
+
+ await waitFor(() => expect(result.current.data?.estimated).toBe(false));
+
+ resolveLedgerTime.mockResolvedValueOnce({
+ seconds: 1_700_000_000,
+ estimated: true,
+ });
+ rerender({ ledger: 4_700_000 });
+
+ await waitFor(() =>
+ expect(result.current.data).toEqual({
+ seconds: 1_700_000_000,
+ estimated: true,
+ }),
+ );
+ expect(resolveLedgerTime).toHaveBeenLastCalledWith(expect.anything(), 4_700_000);
+ });
+
+ it('surfaces a resolver failure without inventing a date', async () => {
+ resolveLedgerTime.mockRejectedValue(new Error('could not resolve'));
+
+ const { result } = renderHook(() => useLedgerTime(4_811_204, RPC_URL));
+
+ await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
+ expect(result.current.data).toBeNull();
+ });
+});
diff --git a/frontend/src/hooks/useLedgerTime.ts b/frontend/src/hooks/useLedgerTime.ts
new file mode 100644
index 0000000..eec8a0c
--- /dev/null
+++ b/frontend/src/hooks/useLedgerTime.ts
@@ -0,0 +1,33 @@
+import { useMemo } from 'react';
+import { rpc } from '@milepost/program';
+import { lookupLedgerTime, type LedgerTime } from '../lib/ledgerTime';
+import { useContractRead, type ContractRead } from './useContractRead';
+
+/**
+ * Resolve the creation date for a programme's ledger.
+ *
+ * Disabled when there is no ledger to resolve (a sample programme, or an index
+ * entry without one), so nothing invents a ledger time. The result is exact
+ * when the RPC retains the ledger and estimated otherwise; see
+ * `lib/ledgerTime.ts`.
+ */
+export function useLedgerTime(
+ createdLedger: number | null,
+ rpcUrl: string,
+): ContractRead {
+ const server = useMemo(
+ () => new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }),
+ [rpcUrl],
+ );
+
+ return useContractRead(
+ async () => ({
+ result:
+ createdLedger === null
+ ? null
+ : await lookupLedgerTime(server, createdLedger),
+ }),
+ [server, createdLedger],
+ { enabled: createdLedger !== null },
+ );
+}
diff --git a/frontend/src/lib/ledgerTime.test.ts b/frontend/src/lib/ledgerTime.test.ts
new file mode 100644
index 0000000..5c2b037
--- /dev/null
+++ b/frontend/src/lib/ledgerTime.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, it, vi } from 'vitest';
+import { rpc } from '@milepost/program';
+import {
+ estimateLedgerTime,
+ LEDGER_CLOSE_SECONDS,
+ lookupLedgerTime,
+} from './ledgerTime';
+
+/**
+ * A programme header's date may be exact (the RPC still retains that ledger)
+ * or estimated from the latest ledger. Both paths must produce a real date,
+ * and an unknowable one must come back as null rather than a plausible-looking
+ * wrong date.
+ */
+
+const CLOSE_SECONDS = Math.floor(Date.parse('2026-03-12T10:00:00Z') / 1000);
+const CLOSE_ISO = '2026-03-12T10:00:00Z';
+
+function fakeServer(options: {
+ getLedgers?: (request: { startLedger: number }) => Promise;
+ getLatestLedger?: () => Promise;
+}): rpc.Server {
+ return {
+ getLedgers: vi.fn(
+ options.getLedgers ?? (async () => ({ ledgers: [] })),
+ ),
+ getLatestLedger: vi.fn(options.getLatestLedger ?? (async () => ({}))),
+ } as unknown as rpc.Server;
+}
+
+describe('estimateLedgerTime', () => {
+ it('counts back from the latest ledger at the testnet cadence', () => {
+ expect(estimateLedgerTime(990, 1000, CLOSE_SECONDS)).toEqual({
+ seconds: CLOSE_SECONDS - 10 * LEDGER_CLOSE_SECONDS,
+ estimated: true,
+ });
+ });
+
+ it('accepts the latest ledger itself as the created ledger', () => {
+ expect(estimateLedgerTime(1000, 1000, CLOSE_SECONDS)).toEqual({
+ seconds: CLOSE_SECONDS,
+ estimated: true,
+ });
+ });
+
+ it('refuses to invent a date from unusable inputs', () => {
+ expect(estimateLedgerTime(990, 1000, null)).toBeNull();
+ expect(estimateLedgerTime(Number.NaN, 1000, CLOSE_SECONDS)).toBeNull();
+ expect(estimateLedgerTime(990, Number.NaN, CLOSE_SECONDS)).toBeNull();
+ // A created ledger ahead of the latest is not something to guess about.
+ expect(estimateLedgerTime(1001, 1000, CLOSE_SECONDS)).toBeNull();
+ });
+});
+
+describe('lookupLedgerTime', () => {
+ it('returns an exact date when the RPC still retains the ledger', async () => {
+ const getLedgers = vi.fn(async (request: { startLedger: number }) => ({
+ ledgers: [{ sequence: request.startLedger, ledgerCloseTime: CLOSE_ISO }],
+ }));
+ const getLatestLedger = vi.fn(async () => ({ sequence: 5_000, closeTime: CLOSE_SECONDS }));
+
+ const result = await lookupLedgerTime(
+ fakeServer({ getLedgers, getLatestLedger }),
+ 4_811_204,
+ );
+
+ expect(result).toEqual({ seconds: CLOSE_SECONDS, estimated: false });
+ expect(getLedgers).toHaveBeenCalledWith({
+ startLedger: 4_811_204,
+ pagination: { limit: 1 },
+ });
+ expect(getLatestLedger).not.toHaveBeenCalled();
+ });
+
+ it('estimates from the latest ledger when the created ledger has aged out', async () => {
+ const getLedgers = vi.fn(async (request: { startLedger: number }) => {
+ // The RPC starts at the earliest ledger it retains, not the one asked for.
+ if (request.startLedger === 4_811_204) {
+ return { ledgers: [{ sequence: 4_900_000, ledgerCloseTime: CLOSE_ISO }] };
+ }
+ return { ledgers: [] };
+ });
+
+ const result = await lookupLedgerTime(
+ fakeServer({
+ getLedgers,
+ getLatestLedger: async () => ({ sequence: 5_000_000, closeTime: CLOSE_SECONDS }),
+ }),
+ 4_811_204,
+ );
+
+ expect(result).toEqual({
+ seconds: CLOSE_SECONDS - (5_000_000 - 4_811_204) * LEDGER_CLOSE_SECONDS,
+ estimated: true,
+ });
+ });
+
+ it('falls back to reading the latest ledger when getLatestLedger omits its close time', async () => {
+ const getLedgers = vi.fn(async (request: { startLedger: number }) => {
+ if (request.startLedger === 4_811_204) return { ledgers: [] };
+ if (request.startLedger === 5_000_000) {
+ return {
+ ledgers: [
+ { sequence: request.startLedger, ledgerCloseTime: CLOSE_SECONDS * 1000 },
+ ],
+ };
+ }
+ return { ledgers: [] };
+ });
+
+ const result = await lookupLedgerTime(
+ fakeServer({
+ getLedgers,
+ getLatestLedger: async () => ({ sequence: 5_000_000 }),
+ }),
+ 4_811_204,
+ );
+
+ expect(result).toEqual({
+ seconds: CLOSE_SECONDS - (5_000_000 - 4_811_204) * LEDGER_CLOSE_SECONDS,
+ estimated: true,
+ });
+ expect(getLedgers).toHaveBeenCalledWith({
+ startLedger: 5_000_000,
+ pagination: { limit: 1 },
+ });
+ });
+
+ it('accepts a close time reported as a numeric string', async () => {
+ const result = await lookupLedgerTime(
+ fakeServer({
+ getLedgers: async (request: { startLedger: number }) => ({
+ ledgers: [
+ {
+ sequence: request.startLedger,
+ ledgerCloseTime: String(CLOSE_SECONDS),
+ },
+ ],
+ }),
+ }),
+ 123,
+ );
+
+ expect(result).toEqual({ seconds: CLOSE_SECONDS, estimated: false });
+ });
+
+ it('returns null when the RPC cannot supply either an exact or a latest close time', async () => {
+ expect(
+ await lookupLedgerTime(
+ fakeServer({
+ getLedgers: async () => {
+ throw new Error('RPC unavailable');
+ },
+ getLatestLedger: async () => {
+ throw new Error('RPC unavailable');
+ },
+ }),
+ 123,
+ ),
+ ).toBeNull();
+
+ // getLedgers answers, but with no usable close time, and the latest ledger
+ // has neither a close time nor a readable ledger.
+ expect(
+ await lookupLedgerTime(
+ fakeServer({
+ getLedgers: async () => ({ ledgers: [{ sequence: 123, ledgerCloseTime: 'not a date' }] }),
+ getLatestLedger: async () => ({ sequence: 900 }),
+ }),
+ 123,
+ ),
+ ).toBeNull();
+ });
+
+ it('returns null when the latest ledger sequence is unusable', async () => {
+ expect(
+ await lookupLedgerTime(
+ fakeServer({
+ getLedgers: async () => ({ ledgers: [] }),
+ getLatestLedger: async () => ({ sequence: 'not a number' }),
+ }),
+ 123,
+ ),
+ ).toBeNull();
+ });
+
+ it('does not use a different ledger as if it were the requested one', async () => {
+ const result = await lookupLedgerTime(
+ fakeServer({
+ getLedgers: async () => ({
+ ledgers: [{ sequence: 999, ledgerCloseTime: CLOSE_ISO }],
+ }),
+ getLatestLedger: async () => ({ sequence: 999 }),
+ }),
+ 123,
+ );
+
+ // The mismatched record is not treated as an exact answer; the fallback
+ // estimates from the latest ledger instead.
+ expect(result).toEqual({
+ seconds: CLOSE_SECONDS - (999 - 123) * LEDGER_CLOSE_SECONDS,
+ estimated: true,
+ });
+ });
+});
diff --git a/frontend/src/lib/ledgerTime.ts b/frontend/src/lib/ledgerTime.ts
new file mode 100644
index 0000000..05edb9c
--- /dev/null
+++ b/frontend/src/lib/ledgerTime.ts
@@ -0,0 +1,128 @@
+/**
+ * When a ledger closed, in Unix seconds.
+ *
+ * A programme's header can show the ledger it was created in, but a ledger
+ * number says nothing to someone deciding whether a programme is new or
+ * abandoned. The RPC's `getLedgers` returns a ledger's close time while it is
+ * in the retention window; for older ledgers the date is estimated from the
+ * latest ledger and marked as approximate.
+ *
+ * Testnet closes a ledger roughly every five seconds. That is the cadence the
+ * estimate uses, and the reason an approximate date is good to within hours
+ * even for a programme created months ago.
+ */
+
+import { rpc } from '@milepost/program';
+
+/** Observed testnet cadence: one ledger about every five seconds. */
+export const LEDGER_CLOSE_SECONDS = 5;
+
+export interface LedgerTime {
+ /** Unix seconds. */
+ seconds: number;
+ /** True when derived from the latest ledger rather than read for this one. */
+ estimated: boolean;
+}
+
+interface LedgerRecord {
+ sequence?: number | string;
+ ledgerCloseTime?: unknown;
+}
+
+interface GetLedgersLike {
+ ledgers?: LedgerRecord[];
+}
+
+interface GetLatestLedgerLike {
+ sequence?: number | string;
+ closeTime?: unknown;
+}
+
+/**
+ * Turn a close time the RPC may report as seconds, milliseconds or an ISO
+ * string into Unix seconds. Anything unusable is null, never a plausible-looking
+ * wrong date.
+ */
+function unixSeconds(value: unknown): number | null {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return Math.floor(value > 1e12 ? value / 1000 : value);
+ }
+ if (typeof value === 'string') {
+ const text = value.trim();
+ if (text === '') return null;
+ const numeric = Number(text);
+ if (Number.isFinite(numeric)) return unixSeconds(numeric);
+ const parsed = Date.parse(text);
+ return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000);
+ }
+ return null;
+}
+
+/** Read one ledger's close time, or null when it is outside the window. */
+async function readCloseTime(server: rpc.Server, ledger: number): Promise {
+ try {
+ const response = (await server.getLedgers({
+ startLedger: ledger,
+ pagination: { limit: 1 },
+ })) as unknown as GetLedgersLike;
+ // Ask for this ledger explicitly. A ledger older than the retention window
+ // either makes the RPC refuse the request or makes it start at the earliest
+ // ledger it still has; a returned record is only this ledger's date if its
+ // sequence matches.
+ const record = response.ledgers?.find(
+ (entry) => Number(entry.sequence) === ledger,
+ );
+ return record ? unixSeconds(record.ledgerCloseTime) : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Estimate a ledger's close time from the latest ledger.
+ *
+ * Returns null when the inputs cannot describe a real date — a future ledger,
+ * a missing close time — rather than inventing one.
+ */
+export function estimateLedgerTime(
+ createdLedger: number,
+ latestSequence: number,
+ latestCloseTime: number | null,
+): LedgerTime | null {
+ if (
+ latestCloseTime === null ||
+ !Number.isFinite(createdLedger) ||
+ !Number.isFinite(latestSequence) ||
+ createdLedger > latestSequence
+ ) {
+ return null;
+ }
+
+ const elapsed = (latestSequence - createdLedger) * LEDGER_CLOSE_SECONDS;
+ return { seconds: latestCloseTime - elapsed, estimated: true };
+}
+
+/**
+ * The creation date for a ledger: exact when the RPC still retains it,
+ * otherwise estimated from the latest ledger. Null when neither is possible.
+ */
+export async function lookupLedgerTime(
+ server: rpc.Server,
+ createdLedger: number,
+): Promise {
+ const exact = await readCloseTime(server, createdLedger);
+ if (exact !== null) return { seconds: exact, estimated: false };
+
+ try {
+ const latest = (await server.getLatestLedger()) as unknown as GetLatestLedgerLike;
+ const latestSequence = Number(latest.sequence);
+ if (!Number.isFinite(latestSequence)) return null;
+
+ const latestCloseTime =
+ unixSeconds(latest.closeTime) ?? (await readCloseTime(server, latestSequence));
+
+ return estimateLedgerTime(createdLedger, latestSequence, latestCloseTime);
+ } catch {
+ return null;
+ }
+}
diff --git a/frontend/src/lib/txLookup.test.ts b/frontend/src/lib/txLookup.test.ts
new file mode 100644
index 0000000..a33260a
--- /dev/null
+++ b/frontend/src/lib/txLookup.test.ts
@@ -0,0 +1,425 @@
+import { describe, expect, it, vi } from 'vitest';
+import { Buffer } from 'buffer';
+import { Address, nativeToScVal, rpc, xdr } from '@milepost/program';
+import {
+ isValidTxHash,
+ lookupTransaction,
+ parseContribution,
+ rpcServer,
+} from './txLookup';
+
+/**
+ * The contribution receipt is rebuilt from a transaction envelope. A wrong
+ * answer here shows someone a receipt for money they did not send, so every
+ * arm of the lookup — success, a different call, a failed transaction and a
+ * hash outside the RPC's window — is exercised below.
+ *
+ * `getTransaction` is mocked; nothing here touches the network. Envelopes are
+ * small hand-built stand-ins for the SDK's XDR accessors, because the public
+ * functions we test only walk the handful of accessors a real envelope
+ * exposes. The argument values themselves are real `ScVal`s, so the conversion
+ * back to a donor address and an i128 amount is the production path.
+ */
+
+const DONOR = 'GAH3D4RM45ETE4W7VDRCWZBPRPT63CJXAGXFYVBC2FGANBZTS4OTKXCA';
+const PROGRAMME = 'CD236SGR4CHW3N5WA5REW7CDLCS4ZLDEX6JVEAIHZK7NSN4W7WD7YDAL';
+const OTHER_CONTRACT = 'CBJWECZ3EUVNAE73YIMT2KWWNRHRF7NNWVKOLQRVOHSKYRYE32XBRPW7';
+const HASH = 'a'.repeat(64);
+
+type FakeInvocation = {
+ functionName: () => unknown;
+ args: () => unknown[];
+ contractAddress: () => unknown;
+};
+
+/** The `invokeContract` args of a `contribute`-shaped call. */
+function invocation(
+ name: string,
+ args: unknown[],
+ contractId = PROGRAMME,
+): FakeInvocation {
+ return {
+ functionName: () => Buffer.from(name),
+ args: () => args,
+ contractAddress: () => new Address(contractId).toScAddress(),
+ };
+}
+
+/** One operation whose body invokes a host function. */
+function invokeOperation(
+ contract: FakeInvocation | null,
+ hostFunctionType = xdr.HostFunctionType.hostFunctionTypeInvokeContract().value,
+): unknown {
+ return {
+ body: () => ({
+ switch: () => ({ value: xdr.OperationType.invokeHostFunction().value }),
+ invokeHostFunctionOp: () => ({
+ hostFunction: () => ({
+ switch: () => ({ value: hostFunctionType }),
+ invokeContract: () => {
+ if (!contract) throw new Error('not an invocation');
+ return contract;
+ },
+ }),
+ }),
+ }),
+ };
+}
+
+/** One operation that is not a contract invocation at all. */
+function otherOperation(): unknown {
+ return {
+ body: () => ({
+ switch: () => ({ value: xdr.OperationType.payment().value }),
+ }),
+ };
+}
+
+function v1Envelope(operations: unknown[], options: { throwing?: boolean } = {}): xdr.TransactionEnvelope {
+ return {
+ switch: () => ({ value: xdr.EnvelopeType.envelopeTypeTx().value }),
+ v1: () => ({
+ tx: () => ({
+ operations: () => {
+ if (options.throwing) throw new Error('unreadable envelope');
+ return operations;
+ },
+ }),
+ }),
+ } as unknown as xdr.TransactionEnvelope;
+}
+
+function v0Envelope(operations: unknown[]): xdr.TransactionEnvelope {
+ return {
+ switch: () => ({ value: xdr.EnvelopeType.envelopeTypeTxV0().value }),
+ v0: () => ({ tx: () => ({ operations: () => operations }) }),
+ } as unknown as xdr.TransactionEnvelope;
+}
+
+function feeBumpEnvelope(inner: xdr.TransactionEnvelope): xdr.TransactionEnvelope {
+ return {
+ switch: () => ({ value: xdr.EnvelopeType.envelopeTypeTxFeeBump().value }),
+ feeBump: () => ({ tx: () => ({ innerTx: () => inner }) }),
+ } as unknown as xdr.TransactionEnvelope;
+}
+
+function serverReturning(response: unknown): rpc.Server {
+ return {
+ getTransaction: vi.fn(async () => response),
+ } as unknown as rpc.Server;
+}
+
+const donorValue = new Address(DONOR).toScVal();
+const amountValue = nativeToScVal(25_0000000n, { type: 'i128' });
+
+describe('isValidTxHash', () => {
+ it('accepts a 64-character hex hash in either case, with surrounding space', () => {
+ expect(isValidTxHash('a'.repeat(64))).toBe(true);
+ expect(isValidTxHash('A'.repeat(64))).toBe(true);
+ expect(isValidTxHash(` ${'0123456789abcdef'.repeat(4)} `)).toBe(true);
+ });
+
+ it('rejects anything that is not 64 hex characters', () => {
+ expect(isValidTxHash('')).toBe(false);
+ expect(isValidTxHash('a'.repeat(63))).toBe(false);
+ expect(isValidTxHash('a'.repeat(65))).toBe(false);
+ expect(isValidTxHash('g'.repeat(64))).toBe(false);
+ expect(isValidTxHash('0x' + 'a'.repeat(62))).toBe(false);
+ });
+});
+
+describe('rpcServer', () => {
+ it('builds a client for http and https endpoints', () => {
+ expect(rpcServer('http://localhost:8000/soroban/rpc')).toBeInstanceOf(rpc.Server);
+ expect(rpcServer('https://soroban-testnet.stellar.org')).toBeInstanceOf(rpc.Server);
+ });
+});
+
+describe('lookupTransaction', () => {
+ it('rebuilds the contribution from a successful transaction', async () => {
+ const envelope = v1Envelope([
+ otherOperation(),
+ invokeOperation(invocation('contribute', [donorValue, amountValue])),
+ ]);
+
+ const result = await lookupTransaction(
+ serverReturning({
+ status: rpc.Api.GetTransactionStatus.SUCCESS,
+ ledger: 5_123_456,
+ createdAt: 1_772_712_000,
+ envelopeXdr: envelope,
+ }),
+ HASH,
+ );
+
+ expect(result).toEqual({
+ status: 'SUCCESS',
+ ledger: 5_123_456,
+ createdAt: 1_772_712_000,
+ contribution: {
+ programmeId: PROGRAMME,
+ donor: DONOR,
+ amount: 25_0000000n,
+ },
+ opCount: 2,
+ });
+ });
+
+ it('reports a successful transaction that calls something else, with its operation count', async () => {
+ const envelope = v1Envelope([
+ invokeOperation(invocation('apply', [donorValue, amountValue])),
+ ]);
+
+ const result = await lookupTransaction(
+ serverReturning({
+ status: rpc.Api.GetTransactionStatus.SUCCESS,
+ ledger: 42,
+ createdAt: 1_772_712_001,
+ envelopeXdr: envelope,
+ }),
+ HASH,
+ );
+
+ expect(result).toMatchObject({
+ status: 'SUCCESS',
+ contribution: null,
+ opCount: 1,
+ });
+ });
+
+ it('does not report a contribution for a contract that does not expose contribute', async () => {
+ // This is the non-registry, non-programme case: a contract call that is
+ // not `contribute` is not a contribution no matter which contract it is.
+ const envelope = v1Envelope([
+ invokeOperation(
+ invocation('transfer', [donorValue, amountValue], OTHER_CONTRACT),
+ ),
+ ]);
+
+ const result = await lookupTransaction(
+ serverReturning({
+ status: rpc.Api.GetTransactionStatus.SUCCESS,
+ ledger: 77,
+ createdAt: 1_772_712_002,
+ envelopeXdr: envelope,
+ }),
+ HASH,
+ );
+
+ expect(result.status).toBe('SUCCESS');
+ if (result.status === 'SUCCESS') {
+ expect(result.contribution).toBeNull();
+ expect(result.opCount).toBe(1);
+ }
+ });
+
+ it('reports a failed transaction and no contribution', async () => {
+ const result = await lookupTransaction(
+ serverReturning({
+ status: rpc.Api.GetTransactionStatus.FAILED,
+ ledger: 5_123_457,
+ createdAt: 1_772_712_003,
+ }),
+ HASH,
+ );
+
+ expect(result).toEqual({
+ status: 'FAILED',
+ ledger: 5_123_457,
+ createdAt: 1_772_712_003,
+ });
+ });
+
+ it('leaves createdAt null when the RPC omits it', async () => {
+ const failed = await lookupTransaction(
+ serverReturning({
+ status: rpc.Api.GetTransactionStatus.FAILED,
+ ledger: 7,
+ }),
+ HASH,
+ );
+ expect(failed).toEqual({ status: 'FAILED', ledger: 7, createdAt: null });
+
+ const succeeded = await lookupTransaction(
+ serverReturning({
+ status: rpc.Api.GetTransactionStatus.SUCCESS,
+ ledger: 8,
+ envelopeXdr: v1Envelope([]),
+ }),
+ HASH,
+ );
+ expect(succeeded).toEqual({
+ status: 'SUCCESS',
+ ledger: 8,
+ createdAt: null,
+ contribution: null,
+ opCount: 0,
+ });
+ });
+
+ it('reports a hash the RPC no longer retains', async () => {
+ const result = await lookupTransaction(
+ serverReturning({ status: rpc.Api.GetTransactionStatus.NOT_FOUND }),
+ HASH,
+ );
+
+ expect(result).toEqual({ status: 'NOT_FOUND' });
+ });
+
+ it('does not fail a successful lookup because the envelope cannot be walked', async () => {
+ const result = await lookupTransaction(
+ serverReturning({
+ status: rpc.Api.GetTransactionStatus.SUCCESS,
+ ledger: 9,
+ createdAt: 1_772_712_004,
+ envelopeXdr: v1Envelope([], { throwing: true }),
+ }),
+ HASH,
+ );
+
+ expect(result).toMatchObject({
+ status: 'SUCCESS',
+ contribution: null,
+ opCount: 0,
+ });
+ });
+});
+
+describe('parseContribution', () => {
+ it('reads the contribution from an envelope v0 transaction', () => {
+ expect(
+ parseContribution(
+ v0Envelope([
+ invokeOperation(invocation('contribute', [donorValue, amountValue])),
+ ]),
+ ),
+ ).toEqual({ programmeId: PROGRAMME, donor: DONOR, amount: 25_0000000n });
+ });
+
+ it('reads through a fee-bump wrapper', () => {
+ const inner = v1Envelope([
+ invokeOperation(invocation('contribute', [donorValue, amountValue])),
+ ]);
+
+ expect(parseContribution(feeBumpEnvelope(inner))).toEqual({
+ programmeId: PROGRAMME,
+ donor: DONOR,
+ amount: 25_0000000n,
+ });
+ });
+
+ it('ignores envelopes it cannot walk', () => {
+ const unknownVersion = {
+ switch: () => ({ value: 999 }),
+ } as unknown as xdr.TransactionEnvelope;
+ const throwingVersion = {
+ switch: () => {
+ throw new Error('bad envelope');
+ },
+ } as unknown as xdr.TransactionEnvelope;
+
+ expect(parseContribution(unknownVersion)).toBeNull();
+ expect(parseContribution(throwingVersion)).toBeNull();
+ });
+
+ it('ignores operations that are not contract invocations', () => {
+ expect(parseContribution(v1Envelope([otherOperation()]))).toBeNull();
+ });
+
+ it('ignores host functions other than invokeContract', () => {
+ const createContract = xdr.HostFunctionType.hostFunctionTypeCreateContract().value;
+ expect(
+ parseContribution(
+ v1Envelope([
+ invokeOperation(
+ invocation('contribute', [donorValue, amountValue]),
+ createContract,
+ ),
+ ]),
+ ),
+ ).toBeNull();
+ });
+
+ it('ignores an invocation that cannot be read', () => {
+ expect(parseContribution(v1Envelope([invokeOperation(null)]))).toBeNull();
+ });
+
+ it('requires exactly a donor and an amount', () => {
+ expect(
+ parseContribution(
+ v1Envelope([
+ invokeOperation(invocation('contribute', [donorValue])),
+ ]),
+ ),
+ ).toBeNull();
+
+ expect(
+ parseContribution(
+ v1Envelope([
+ invokeOperation(
+ invocation('contribute', [donorValue, amountValue, amountValue]),
+ ),
+ ]),
+ ),
+ ).toBeNull();
+ });
+
+ it('requires the first argument to be an address and the second an i128', () => {
+ const numberValue = nativeToScVal(7, { type: 'u32' });
+ const stringValue = nativeToScVal('not an amount');
+
+ expect(
+ parseContribution(
+ v1Envelope([
+ invokeOperation(invocation('contribute', [numberValue, amountValue])),
+ ]),
+ ),
+ ).toBeNull();
+
+ expect(
+ parseContribution(
+ v1Envelope([
+ invokeOperation(invocation('contribute', [donorValue, stringValue])),
+ ]),
+ ),
+ ).toBeNull();
+ });
+
+ it('skips a malformed call and keeps looking for a valid one', () => {
+ const malformed = invocation('contribute', [
+ null as unknown as xdr.ScVal,
+ amountValue,
+ ]);
+
+ expect(
+ parseContribution(
+ v1Envelope([
+ invokeOperation(malformed),
+ invokeOperation(
+ invocation('contribute', [donorValue, amountValue], OTHER_CONTRACT),
+ ),
+ ]),
+ ),
+ ).toEqual({
+ programmeId: OTHER_CONTRACT,
+ donor: DONOR,
+ amount: 25_0000000n,
+ });
+ });
+
+ it('skips an invocation whose contract address is not address-shaped', () => {
+ const broken: FakeInvocation = {
+ functionName: () => Buffer.from('contribute'),
+ args: () => [donorValue, amountValue],
+ contractAddress: () => {
+ throw new Error('not an address');
+ },
+ };
+
+ expect(parseContribution(v1Envelope([invokeOperation(broken)]))).toBeNull();
+ });
+
+ it('returns null for an empty transaction', () => {
+ expect(parseContribution(v1Envelope([]))).toBeNull();
+ });
+});
diff --git a/frontend/src/pages/ProgrammeDetail.test.tsx b/frontend/src/pages/ProgrammeDetail.test.tsx
index 52a799e..fd22a73 100644
--- a/frontend/src/pages/ProgrammeDetail.test.tsx
+++ b/frontend/src/pages/ProgrammeDetail.test.tsx
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Buffer } from 'buffer';
import { ProgrammeDetail } from './ProgrammeDetail';
+import { formatDate } from '../lib/format';
// Issue #342 — the programme detail page. One page shows a real programme's
// on-chain reads (phase, money, config, registry), what each phase lets you do,
@@ -111,6 +112,24 @@ vi.mock('../lib/indexer', async (importOriginal) => ({
const tokenNameMock = vi.hoisted(() => vi.fn(async () => ({ result: 'native' })));
+// The header resolves the creation ledger's close time over RPC. Programme
+// detail tests are about the page's arrangement, not RPC pagination, so the
+// hook is stubbed; ledgerTime.test.ts covers the resolution itself.
+const ledgerTimeMock = vi.hoisted(() => ({
+ value: null as { seconds: number; estimated: boolean } | null,
+}));
+
+vi.mock('../hooks/useLedgerTime', () => ({
+ useLedgerTime: () => ({
+ data: ledgerTimeMock.value,
+ error: null,
+ archived: false,
+ loading: false,
+ fetching: false,
+ refetch: vi.fn(),
+ }),
+}));
+
vi.mock('../lib/token', async (importOriginal) => ({
...(await importOriginal()),
fetchTokenName: tokenNameMock,
@@ -134,6 +153,7 @@ beforeEach(() => {
programme = makeProgramme();
registry = makeRegistry();
indexed = [];
+ ledgerTimeMock.value = null;
mocks.address = VIEWER;
mocks.announce.mockClear();
tokenNameMock.mockClear();
@@ -191,6 +211,31 @@ describe('ProgrammeDetail — a deployed programme', () => {
// Renders the whole page with every read; slow on a loaded CI runner.
}, 20_000);
+ it('shows the ledger date when the close time was read exactly', async () => {
+ indexed = [{ id: REAL_ID, name: 'Riverside horticulture grants', creator: null, createdLedger: 4811204 }];
+ const seconds = Math.floor(Date.parse('2026-03-12T10:00:00Z') / 1000);
+ ledgerTimeMock.value = { seconds, estimated: false };
+ renderProgramme(`/programme/${REAL_ID}`);
+
+ await screen.findByRole('heading', { name: 'Riverside horticulture grants' });
+ const time = screen.getByText(formatDate(seconds)).closest('time');
+ expect(time).toBeTruthy();
+ expect(time?.parentElement?.textContent).not.toContain('about');
+ expect(screen.getByText('4,811,204')).toBeTruthy();
+ });
+
+ it('marks the ledger date as approximate when it was estimated', async () => {
+ indexed = [{ id: REAL_ID, name: 'Riverside horticulture grants', creator: null, createdLedger: 4811204 }];
+ const seconds = Math.floor(Date.parse('2026-03-12T10:00:00Z') / 1000);
+ ledgerTimeMock.value = { seconds, estimated: true };
+ renderProgramme(`/programme/${REAL_ID}`);
+
+ await screen.findByRole('heading', { name: 'Riverside horticulture grants' });
+ const time = screen.getByText(formatDate(seconds)).closest('time');
+ expect(time?.parentElement?.textContent).toContain('about');
+ expect(screen.getByText('4,811,204')).toBeTruthy();
+ });
+
it('says when it is showing the seeded demo programme', async () => {
renderProgramme('/programme');
diff --git a/frontend/src/pages/ProgrammeDetail.tsx b/frontend/src/pages/ProgrammeDetail.tsx
index ab6f8f0..d186c16 100644
--- a/frontend/src/pages/ProgrammeDetail.tsx
+++ b/frontend/src/pages/ProgrammeDetail.tsx
@@ -19,6 +19,7 @@ import { ProgrammeActions } from "../components/programme/ProgrammeActions";
import { AddressChip, Badge, Button, Card, Deadline, Field, List, ListRow, PhaseBadge, Stat } from "../components/ui";
import { useSoroban } from "../context/useSoroban";
import { useContractRead, useContractResult, useIndexedList, useProgramme } from "../hooks";
+import { useLedgerTime } from "../hooks/useLedgerTime";
import { fetchProgrammes } from "../lib/indexer";
import { directoryHref } from "../lib/programmeView";
import { formatAmount } from "../lib/amount";
@@ -491,7 +492,7 @@ function MetadataCard({ committedHash }: { committedHash: string }) {
export const ProgrammeDetail = () => {
const { id: programmeId, client: programme, isDefault } = useProgramme();
- const { registry } = useSoroban();
+ const { registry, rpcUrl } = useSoroban();
const location = useLocation();
const [nowMs, setNowMs] = useState(() => Date.now());
@@ -589,6 +590,11 @@ export const ProgrammeDetail = () => {
// per award at `finalize` — so it shows none rather than a guess.
const isSample = isSampleProgrammeId(programmeId);
const plannedMode = isSample ? (FIXTURE_CHAIN[programmeId]?.mode ?? null) : null;
+ // The ledger's close time turns the ledger number into a date. Sample
+ // programmes have no real ledger to read, and their fixture ledger must not
+ // be presented as a live date.
+ const createdLedger = indexEntry?.createdLedger ?? null;
+ const createdAt = useLedgerTime(isSample ? null : createdLedger, rpcUrl);
// The contract opens refunds on cancellation or once the release deadline
// has passed; the money panel shows refundable funds only from then.
const refundsOpen =
@@ -632,6 +638,7 @@ export const ProgrammeDetail = () => {
name={indexEntry?.name ?? "Programme detail"}
creator={config.data?.creator ?? null}
createdLedger={indexEntry?.createdLedger ?? null}
+ createdAt={createdAt.data}
phase={phase.data?.tag ?? null}
phaseError={Boolean(phase.error)}
verified={isRegistered.data ?? undefined}
@@ -753,6 +760,7 @@ export const ProgrammeDetail = () => {
programmeId={programmeId}
phase={phaseName}
quorum={config.data?.quorum ?? 1}
+ minimumAward={config.data?.minimum_award ?? 0n}
isSample={isSampleProgrammeId(programmeId)}
asset={ASSET_LABEL}
/>