{relativeTime(entry.created_at)}
diff --git a/src/lib/schemas/wallet.ts b/src/lib/schemas/wallet.ts
new file mode 100644
index 0000000..7ea0ba2
--- /dev/null
+++ b/src/lib/schemas/wallet.ts
@@ -0,0 +1,11 @@
+import { z } from "zod";
+
+export const topupWalletSchema = z.object({
+ amount: z
+ .number({ invalid_type_error: "Amount must be a number." })
+ .min(100, "Minimum top-up is ₹100.")
+ .max(100000, "Maximum top-up is ₹1,00,000."),
+ idempotency_key: z.string().uuid(),
+});
+
+export type TopupWalletInput = z.infer;
diff --git a/supabase/migrations/20260517201500_topup_wallet.sql b/supabase/migrations/20260517201500_topup_wallet.sql
new file mode 100644
index 0000000..1c5b3a5
--- /dev/null
+++ b/supabase/migrations/20260517201500_topup_wallet.sql
@@ -0,0 +1,86 @@
+-- 1. Allow nullable job_id for non-escrow ledger entries (topup, withdraw).
+-- The enum already permits these types; the NOT NULL was an oversight.
+alter table public.escrow_ledger
+ alter column job_id drop not null;
+
+-- 2. RPC: top up the caller's wallet (test mode — no real payment).
+create or replace function public.topup_wallet(
+ p_amount numeric,
+ p_idempotency_key uuid
+)
+returns table (
+ available_balance numeric,
+ ledger_id uuid
+)
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ v_profile_id uuid;
+ v_min_amount constant numeric := 100;
+ v_max_amount constant numeric := 100000;
+ v_ledger_id uuid;
+ v_new_balance numeric;
+begin
+ -- 1. Auth check
+ v_profile_id := auth.uid();
+ if v_profile_id is null then
+ raise exception 'not_authenticated' using errcode = '28000';
+ end if;
+
+ -- 2. Validate amount (test-mode bounds: ₹100 – ₹1,00,000)
+ if p_amount is null or p_amount < v_min_amount or p_amount > v_max_amount then
+ raise exception 'invalid_amount' using errcode = '22023';
+ end if;
+
+ -- 3. Idempotency: if a topup ledger row with this reference already exists,
+ -- return the current balance + existing ledger id without re-crediting.
+ select id into v_ledger_id
+ from escrow_ledger
+ where reference_id = p_idempotency_key
+ and type = 'topup'
+ limit 1;
+
+ if v_ledger_id is not null then
+ select w.available_balance into v_new_balance
+ from wallets w
+ where w.profile_id = v_profile_id;
+ return query select v_new_balance, v_ledger_id;
+ return;
+ end if;
+
+ -- 4. Defensive: ensure wallet exists (the create_wallet_for_profile trigger
+ -- should have already done this, but belt-and-suspenders).
+ insert into wallets (profile_id)
+ values (v_profile_id)
+ on conflict (profile_id) do nothing;
+
+ -- 5. Credit the wallet and capture the new balance
+ update wallets
+ set available_balance = available_balance + p_amount
+ where profile_id = v_profile_id
+ returning available_balance into v_new_balance;
+
+ -- 6. Audit row in escrow_ledger
+ -- job_id / milestone_id / from_wallet all null (external → wallet)
+ insert into escrow_ledger (
+ job_id, milestone_id, from_wallet, to_wallet, amount, type, reference_id
+ )
+ values (
+ null, null, null, v_profile_id, p_amount, 'topup', p_idempotency_key
+ )
+ returning id into v_ledger_id;
+
+ return query select v_new_balance, v_ledger_id;
+end;
+$$;
+
+-- 3. Grants
+revoke all on function public.topup_wallet(numeric, uuid) from public;
+grant execute on function public.topup_wallet(numeric, uuid) to authenticated;
+
+-- 4. Index to keep the idempotency lookup fast
+create index if not exists escrow_ledger_topup_reference_idx
+ on public.escrow_ledger (reference_id)
+ where type = 'topup';
\ No newline at end of file
diff --git a/supabase/migrations/20260517223900_topup_wallet_hardening.sql b/supabase/migrations/20260517223900_topup_wallet_hardening.sql
new file mode 100644
index 0000000..d85d7e1
--- /dev/null
+++ b/supabase/migrations/20260517223900_topup_wallet_hardening.sql
@@ -0,0 +1,115 @@
+-- ──────────────────────────────────────────────────────────────────────────
+-- Hardening for topup_wallet (PR #16 review)
+-- ──────────────────────────────────────────────────────────────────────────
+
+-- 1. Replace non-unique partial index with UNIQUE partial index.
+-- Rename to follow idx_
_ convention.
+drop index if exists public.escrow_ledger_topup_reference_idx;
+
+create unique index if not exists idx_escrow_ledger_reference_id
+ on public.escrow_ledger (reference_id)
+ where type = 'topup';
+
+-- 2. job_id may only be null for non-job-scoped ledger types.
+alter table public.escrow_ledger
+ add constraint escrow_ledger_job_id_required_for_job_types
+ check (type in ('topup', 'withdraw') or job_id is not null);
+
+-- 3. Wallet owners can read their own non-job-scoped ledger rows.
+-- Additive: stacks via OR with the existing is_job_participant(job_id) policy.
+drop policy if exists "escrow_ledger_select_wallet_owner" on public.escrow_ledger;
+
+create policy "escrow_ledger_select_wallet_owner"
+ on public.escrow_ledger
+ for select
+ to authenticated
+ using (
+ auth.uid() = from_wallet
+ or auth.uid() = to_wallet
+ );
+
+-- 4. Hardened topup_wallet:
+-- - null idempotency key rejected at DB boundary
+-- - INSERT ledger row FIRST so the UNIQUE index catches concurrent racers
+-- - exception when unique_violation re-reads the winning row
+create or replace function public.topup_wallet(
+ p_amount numeric,
+ p_idempotency_key uuid
+)
+returns table (available_balance numeric, ledger_id uuid)
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ v_profile_id uuid := auth.uid();
+ v_ledger_id uuid;
+ v_available numeric;
+begin
+ if v_profile_id is null then
+ raise exception 'not_authenticated' using errcode = '42501';
+ end if;
+
+ if p_amount is null or p_amount < 100 or p_amount > 100000 then
+ raise exception 'invalid_amount' using errcode = '22023';
+ end if;
+
+ -- Defends against direct RPC callers bypassing the action-layer Zod check.
+ if p_idempotency_key is null then
+ raise exception 'invalid_idempotency_key' using errcode = '22023';
+ end if;
+
+ -- Ensure wallet row exists.
+ insert into public.wallets (profile_id)
+ values (v_profile_id)
+ on conflict (profile_id) do nothing;
+
+ -- Fast path: idempotency hit.
+ select id into v_ledger_id
+ from public.escrow_ledger
+ where reference_id = p_idempotency_key
+ and type = 'topup'
+ limit 1;
+
+ if found then
+ select w.available_balance into v_available
+ from public.wallets w
+ where w.profile_id = v_profile_id;
+ return query select v_available, v_ledger_id;
+ return;
+ end if;
+
+ -- Write ledger BEFORE crediting. UNIQUE index serializes concurrent calls.
+ begin
+ insert into public.escrow_ledger (
+ job_id, milestone_id, from_wallet, to_wallet, amount, type, reference_id
+ )
+ values (
+ null, null, null, v_profile_id, p_amount, 'topup', p_idempotency_key
+ )
+ returning id into v_ledger_id;
+ exception when unique_violation then
+ -- Concurrent caller won. Re-read the winning row + current balance.
+ select id into v_ledger_id
+ from public.escrow_ledger
+ where reference_id = p_idempotency_key
+ and type = 'topup'
+ limit 1;
+ select w.available_balance into v_available
+ from public.wallets w
+ where w.profile_id = v_profile_id;
+ return query select v_available, v_ledger_id;
+ return;
+ end;
+
+ -- Credit wallet. We own the ledger row → no double-credit possible.
+ update public.wallets
+ set available_balance = available_balance + p_amount
+ where profile_id = v_profile_id
+ returning available_balance into v_available;
+
+ return query select v_available, v_ledger_id;
+end;
+$$;
+
+grant execute on function public.topup_wallet(numeric, uuid) to authenticated;
\ No newline at end of file
diff --git a/supabase/migrations/20260518002100_topup_wallet_per_user_idempotency.sql b/supabase/migrations/20260518002100_topup_wallet_per_user_idempotency.sql
new file mode 100644
index 0000000..f5b9a3e
--- /dev/null
+++ b/supabase/migrations/20260518002100_topup_wallet_per_user_idempotency.sql
@@ -0,0 +1,100 @@
+-- ──────────────────────────────────────────────────────────────────────────
+-- Scope top-up idempotency to the recipient wallet (PR #16 codeant re-review)
+--
+-- The previous UNIQUE index and function lookups keyed on reference_id only,
+-- meaning a UUID collision across two different users could suppress one of
+-- their top-ups. v4 UUIDs make this statistically impossible, but the bot is
+-- right that the data model should enforce per-user scoping as defense in
+-- depth. Both the unique constraint and the function's two lookup paths now
+-- include the recipient wallet (to_wallet = auth.uid()).
+-- ──────────────────────────────────────────────────────────────────────────
+
+-- 1. Replace global unique index with per-recipient unique index.
+drop index if exists public.idx_escrow_ledger_reference_id;
+
+create unique index if not exists idx_escrow_ledger_topup_owner_reference
+ on public.escrow_ledger (to_wallet, reference_id)
+ where type = 'topup';
+
+-- 2. Update topup_wallet to include caller-ownership predicate in both
+-- idempotency lookups (pre-check + unique_violation recovery).
+create or replace function public.topup_wallet(
+ p_amount numeric,
+ p_idempotency_key uuid
+)
+returns table (available_balance numeric, ledger_id uuid)
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ v_profile_id uuid := auth.uid();
+ v_ledger_id uuid;
+ v_available numeric;
+begin
+ if v_profile_id is null then
+ raise exception 'not_authenticated' using errcode = '42501';
+ end if;
+
+ if p_amount is null or p_amount < 100 or p_amount > 100000 then
+ raise exception 'invalid_amount' using errcode = '22023';
+ end if;
+
+ if p_idempotency_key is null then
+ raise exception 'invalid_idempotency_key' using errcode = '22023';
+ end if;
+
+ insert into public.wallets (profile_id)
+ values (v_profile_id)
+ on conflict (profile_id) do nothing;
+
+ -- Fast path: idempotency hit, scoped to the calling wallet.
+ select id into v_ledger_id
+ from public.escrow_ledger
+ where reference_id = p_idempotency_key
+ and type = 'topup'
+ and to_wallet = v_profile_id
+ limit 1;
+
+ if found then
+ select w.available_balance into v_available
+ from public.wallets w
+ where w.profile_id = v_profile_id;
+ return query select v_available, v_ledger_id;
+ return;
+ end if;
+
+ -- INSERT first; per-recipient UNIQUE index serializes concurrent calls.
+ begin
+ insert into public.escrow_ledger (
+ job_id, milestone_id, from_wallet, to_wallet, amount, type, reference_id
+ )
+ values (
+ null, null, null, v_profile_id, p_amount, 'topup', p_idempotency_key
+ )
+ returning id into v_ledger_id;
+ exception when unique_violation then
+ -- Re-read the winning row, scoped to the calling wallet.
+ select id into v_ledger_id
+ from public.escrow_ledger
+ where reference_id = p_idempotency_key
+ and type = 'topup'
+ and to_wallet = v_profile_id
+ limit 1;
+ select w.available_balance into v_available
+ from public.wallets w
+ where w.profile_id = v_profile_id;
+ return query select v_available, v_ledger_id;
+ return;
+ end;
+
+ update public.wallets
+ set available_balance = available_balance + p_amount
+ where profile_id = v_profile_id
+ returning available_balance into v_available;
+
+ return query select v_available, v_ledger_id;
+end;
+$$;
+
+grant execute on function public.topup_wallet(numeric, uuid) to authenticated;
\ No newline at end of file